Skip to content

Decisions

📌 How to read this page

Cards are the single home for a decision: state it here (and the dated entry in 17-changelog), then link to it from everywhere else rather than restating it. A superseded decision is never deleted — it keeps its card and gains a "⚠️ Superseded by …" banner, so the reasoning that produced it stays readable.

Each card's badge carries its own status, so trust the badge on the card, not a page-level caveat. The v3.0 dimension model resolves several earlier BD/PD items — see v3.0 Decisions.

Open items for business, product, and legal alignment. Click any card to expand and see full options.

Decision index

All 152 v3 decision cards, newest first. Cards appear on the page in historical clusters rather than in number order, so use this index rather than scrolling. Superseded cards keep their entry — a decision is never removed, only banner-marked.

Open the index (152 cards)
CardDecision
v3-157claimRedemptionFallback is removed, which withdraws the permissionless exit guarantee rather than tidying a function
v3-156Retired number, deliberately vacant. Registered on 2026-09-03 for the Issuer SPV role redesign (f361d976) and withdrawn the same day (97181db9) while that redesign is re-planned. 🔴 Not reused — the registering commit was pushed, so the remote history and the deployed site both carry v3-156 with that meaning, and a second meaning on the same number would leave anyone reading the history unable to tell which. This is the first gap in the sequence; it is a gap on purpose, not a missing card to fill.
v3-155The reserve share routes to an external wallet, which turns reserve_bps from a retention ratio into a routing ratio
v3-154The rate field a pool carries is decided by its accrual mode, because a pool holding both has two answers to one question
v3-153Interest is charged on paid-in capital, so a write-down moves the NAV and does not move the bill
v3-152Wind-down stops recomputing the price, because once the reserve is outside the pool there is nothing meaningful left to divide by
v3-151Reinvest comes off every screen, and what actually closes the path is a column default rather than the missing buttons
v3-150The per-epoch settlement cap leaves the product surface and stays in the contract
v3-149An instant redemption cannot be taken back, and the on-chain branch that would allow it simply loses its caller
v3-148How a holding leaves is derived from the axes and never stored, and the judgement that answers it takes all of them
v3-147After maturity the coupon date is the repayment cycle's own, and the on-chain promise finally gets written rather than forecast
v3-146The lock-up’s three dates are one create-time decision, so the offering close stops being an editable field and an open-ended pool carries no lock-up
v3-145The term is counted from the offering close, which retires the W9 ceiling and the late-depositor defect with it
v3-144The lock-up counts from the offering close, and both anchors are live at once because pools are clones
v3-143Escrowed LP keeps earning, and the accrual basis stopped being a set that already had somebody else's name on it
v3-141The offering close gets its own column, because end_date was answering "subscriptions are over" and "the term is over" with one number
v3-142A closed offering can be reopened, and the chain had to be told — the screens had been promising it for two and a half weeks
v3-140AUTO turned off the only thing watching and handed the job to nobody, so the switch comes out rather than the reminder
v3-139The funding-date endpoint takes a cycle number, the pool row still speaks only for the accepting cycle, and the chain checks both neighbours
v3-138The key split left every new pool unable to sign, and the halt role is handed over before it is taken away
v3-137Funding left over in a cycle stays in that cycle, and the missing sweep is a contract decision nobody has made
v3-136A date a stored rule produces may be shown to a person and nothing else
v3-135The funding-date reminder is switched by the state of the plan, not by a setting chosen months earlier
v3-134Two writers put the same list on-chain in opposite orders, and the reasons are not the same reason
v3-133The deploy writes every cycle's date, because an unwritten cycle reads as a confident wrong answer that hardens with each settlement
v3-132The repayment plan is stored as a list of dates, and the rule that produced it is kept only so a human can produce the same list twice
v3-131A pool that repays over several cycles after maturity is a combination the chain already allows, and a wizard guard is the only thing refusing it
v3-130The lock-up anchor is emitted, not inferred, and a reinvestment anchors exactly as a deposit does
v3-129Type shrinks on phones, columns grow on monitors: a big screen earns more content, not bigger content
v3-128The hold-back comes out: a capability nobody pulls still costs every formula that carries it
v3-127The SBT is the identity source of truth, so a mint may not invent half of it and a login may not collapse the other
v3-126A dropped table's defaults outlived it: the redemption view had PENDING_RESERVE inverted and no NULL-safe way to ask "on hold"
v3-125The portfolio prices principal, and principal is capped at par: the position delta is a writedown, not a return
v3-124Three time bases stay apart: a cycle is "every N days", a month is 30, yield is the calendar
v3-123The Yield review signs its whole priced set through the redemption runner, and counts deposits rather than settlements
v3-122Balances are folded from an append-only ledger; the four money tables are gone, legacy stays
v3-121The Yield screen answers "who do I chase" and "who got paid", from records that already existed
v3-120A yield period becomes actionable a week before it is late, and says which it is
v3-119A cycle is what gets funded, so a cycle is what the endpoint takes
v3-11804-pool-models gets an index and progressive disclosure; nothing is cut
v3-117Governance timelocks get an execute-due nudge, and a third-party execute now reaches the DB
v3-11607-redemption keeps the mechanics and drops the archaeology; three "not implemented" callouts were stale
v3-11505-investment-lifecycle is the investor's journey; the mechanics belong to their owners
v3-11404-pool-models is a concept spec, not an archive: archaeology moves to its owners
v3-113Backend docs IA: one organizing axis, ten groups
v3-112The hold-back is a dormant lever, and "the 90%" is a configured remainder
v3-111Reinvest prices at effectiveNav, like a deposit
v3-110ARCHIVED was never a lifecycle status, and CLOSED has no way in
v3-109Reserve is not a loss layer; the NAV denominator is totalSupply
v3-108Notifications rebuilt: one row doing two jobs becomes event → notification → delivery
v3-107The anchored epoch schedule is never installed on-chain; funding-date provenance is a column, not a chain read
v3-106A tranche write-down is an ADMIN act; IMPAIRED exists only on-chain; only a wiped tranche is impaired
v3-105A partially-filled cancel must claim first; the funding-date badge reads the database, not the chain
v3-104A due-but-unrecorded yield period is a read-model row, not a front-end join: the admin Yield list gets include_due
v3-103Notification copy SoT is the code, not the sheet; the registry is reconciled at 56 keys; delivery is still stopped at the send worker
v3-102distributeYield + withdrawFees become one settleYield: the half-settled period is now unrepresentable
v3-101Amount axes are types, not comments; the contract stays as-is; two live 1e12 bugs fixed
v3-100Epoch engine: the pot became a scalar; v3-93's six open items closed; one live accounting leak found
v3-99Redemption exit gates; Return position replaces "reject", ADMIN-only, reason required
v3-98KYB (institution onboarding) dropped: the platform onboards individuals only
v3-97Freeze milestones get notified: dates up front and two resumption notices; dedupe keyed to the freeze cycle
v3-96Recording yield = distributing it; recovery is status-scoped; distribute tx_hash persisted before finalize
v3-95Status banner copy is a correctness surface: SoT table + five rules; fabricated investor drawer removed
v3-94Pause is manual-only; overdue never auto-pauses. Auto-pause requires pause provenance first
v3-93Epoch request window is a hard gate; funding date is admin-entered per cycle; cancel window = request window (amends v3-91)
v3-92Status-flag auto-clear must also unpause on-chain (amends v3-78)
v3-91Epoch redemption engine redesign: calendar-anchor schedule + demand freeze + settlement escrow (double-commit fix) + yield-stop-at-request
v3-90Joob fund-data semantics: NPL at DPD 90 / write-off ~180 DPD, cash-received fund value, EOD 09:00 refresh
v3-89Redemption terminology: OPEN_ENDED is canonical ("revolving" = UX nickname); open-ended pools have no early-exit penalty
v3-88Admin redemption-step wizard: consistency + config guards (D1–D9)
v3-87Pool detail IA: Controls tab is actions-only; NAV trend → Overview chart; governance/lifecycle history → global Audit Log
v3-86Audit Log re-scope: audit-first SoT + snapshot actor + expanded write coverage + Audit/Activity split
v3-85Early-exit penalty destination: fund_wallet, not Pool reserve (all penalty types)
v3-84Reinstate YIELD_BASED penalty (lock-up pools, release gated to first yield distribution) — reverses the v3-79 deprecation
v3-83Lock-up is independent of penalty type (removes the NO_EARLY⇔lockup=0 coupling)
v3-82Partner funding auto-settles a PENDING_RESERVE redemption (removes admin approve gate)
v3-81Create validation + REVOLVING / APY display: maturity > lockup, REVOLVING publish exemption, per-year APY
v3-80Rule #7 revision: admin create/edit inputs accept percent (one-way lossless → bps)
v3-79Early-exit penalty → PRINCIPAL_BASED (YIELD_BASED deprecated) + partial-redemption policy
v3-78Pool status flags: auto-clear exclusivity + wind-down does not freeze
v3-77Design system consolidated into one shared package (web + admin SoT)
v3-76Redemption Flow overhaul: reserve-gated instant settle + lockup hard-block
v3-75KYB (institution) onboarding policy: jurisdiction-aligned, UBO ≥20% collect / per-jurisdiction determine
v3-74Qualified-investor gating (non-US Reg S): investor_tier → investor_status + eligibility_mode, backend-enforced
v3-73FE displays ratio config as percent; storage / input / API / contract stay bps (one-way display formatter)
v3-72Fund-data DPD/NPL model: NPL = on-book delinquent (leading), cumulative loss = realized write-off (in NAV); dynamic buckets + risk-badge signal
v3-71Field-governance matrix corrected against contracts; on-chain reclassification + open timelock/legal questions
v3-70Money-path naming unified (capital / fee axis); new canonical 23-money-path doc
v3-69Fee structure: 3 recipients (Aset / Fund / operation); Pool mgmt routes to fund_fee_wallet at distribution
v3-68NAV guard rails (circuit breaker / deviation cap / staleness) are operational — no investor UI
v3-67Epoch redemption gating cap base = totalDeposited, not TVL (doc/comment correction)
v3-66NO_EARLY means no lockup (lockup_days = 0)
v3-65Fund Data grounding: total_subscribed, fund-level service providers, NPL/write-off delinquency
v3-64Reinvest is same-pool only (no cross-pool reinvest)
v3-63Reserve recovery is pool-level, not per-holder
v3-62Number / USD / date display conventions
v3-61Per-investor cap (max_investment) hidden; contract field left dormant
v3-60Country code canonical format = ISO alpha-3
v3-59DPD delinquency buckets generalized (fixed 30/60/90 → jurisdiction-defined dynamic)
v3-58Permissionless LP transfers: whitelist gate removed (verification enforced at value boundaries)
v3-57Pool Updates (WO-6) RBAC formalized: Operator opt-in, soft-delete retention, MATERIAL_EVENT revision audit
v3-56Full-amount LP minting: reserve split no longer dilutes the investor's claim
v3-55Partner fund-data metrics renamed asset-class-neutral (total_rni/total_npl → realized_income/cumulative_impairment)
v3-54FM wallet signing hardening: fund_wallet visibility + client-sign enforcement + bound-wallet display (W8/W9)
v3-53FM wallet signing: depositYield / fundRedemption client-signed (B3 + 2-phase split)
v3-52Investor display name = KYC legal name (non-editable) + SumSub backfill
v3-51Yield reconciler: claimable_yield = on-chain pendingYield (미청구 + 적립)
v3-50Tranche loss waterfall: build BE capability now (loss-only) + create-flow = Method A
v3-49FM yield-distribution due + overdue notifications (scheduler producer)
v3-48BE-FE gap audit: fund-member edit + NAV propose wired; retry-lp-mint removed
v3-47Admin user management: edit name + delete reachable for all roles (authority = delete rule)
v3-46Redemption model (instant vs epoch) is a gating-structure choice, orthogonal to maturity
v3-45Investor email registration + verification (notification email channel)
v3-44Notification system: code copy registry + Direction-B email template + payload jsonb schema
v3-43Pool field editability: phase-aware (DRAFT vs ACTIVE); capacity raise-only, min_investment locked
v3-42Fund Manager activity access: fund-scoped operational view; compliance Audit Log stays admin-only
v3-41Audit log = unified two-stream feed (human actions + on-chain mirror); 5y retention
v3-40Preview (marketing/showcase) pool tier + register-interest CTA
v3-39Tranche first-loss = v1 off-chain (Lambda) trust model; on-chain waterfall deferred
v3-38Epoch duration is set at pool creation only; live change deferred to v2 timelock governance
v3-37Adopt Gnosis Safe multisig for cold roles (Admin 3-of-5 + separate Pauser 2-of-3)
v3-36Per-fund stats drill-down + shortfall KPI
v3-35Drop collateral_type enum (completes v3-07)
v3-34Finalize FM_ACCEPTED removal (v3-04 follow-through)
v3-33Managed (embedded) wallet allowed, with "Aset holds zero key shares" constraint
v3-32updateNAV bounds in core + role-admin cold-key/timelock (no multisig)
v3-31Exit-right fallback: permissionless timelocked redemption claim
v3-30Targeted modularization: KYC-only upgradeable proxy, core immutable
v3-29DB↔on-chain field sync & PATCH enforcement
v3-28Emergency freeze exit-right: time-bound + redeem fallback (Option D)
v3-27Non-custody key governance: money-path non-upgradeable, periphery timelocked
v3-26fund_id required on every pool (no Aset-custody direct pools)
v3-25Pool Risk Tier badge (Low/Med/High), receivables-first
v3-20Yield = Manual Claim only (drop AUTO distribution + yield_trigger)
v3-19KYC validity / expiry policy (expiry blocks deposit, not redemption)
v3-18Minimal on-chain config + KYC compliance enforced on-chain
v3-17Cross-VASP KYC strategy (SumSub Reusable KYC when shared provider, else fast re-KYC)
v3-16Reserve consumption integrated into updateNAV (atomic; loss-absorption framing superseded by R8)
v3-15Drop PARTNER_REPORTED; Aset always processes NAV
v3-14Tranche via linked SINGLE pools (supersedes v3-09)
v3-13Automated NAV writedown via Joob DPD API (OJK schedule)
v3-12Unify wind-down with NAV mechanism + add IMPAIRED state
v3-11Absorb PlatformEscrow into PlatformPool (5 → 4 contracts)
v3-10Per-pool KYC gating (level + jurisdiction whitelist)
v3-09Tranche structure as dimension
v3-08NAV data source dimension
v3-07collateral_type enum → free-text + ratio
v3-06Emergency Wind-Down (60+30 day timelock)
v3-05Two-level pool pause (soft + emergency)
v3-04PENDING_RESERVE replaces FM_ACCEPTED
v3-03Remove Receipt NFT; deposits are atomic
v3-02Aset always mints LP (PLATFORM_ISSUED only)
v3-01Replace pool_type binary with 24 dimensions

Older BD/PD items (business · product · legal) sit at the bottom of the page under Business Decisions.


v3.0 Decisions

Major decisions made during the v3.0 dimension-model redesign. Each replaces or extends an earlier BD/PD.

On dates: every decision below carries its own Date: line (the date it was decided). New decisions must include one. The changelog (17-changelog) is the authoritative timeline. (The #v3-0-decisions-2026-05-29 section anchor is kept only so existing links don't break.)

v3-01 — Replace pool_type binary with 24 dimensions ✅ Decided

v3-01 — Pool Configuration Model

Date: 2026-05-29

Decision: Replace pool_type enum (AS_POOL/FUND_POOL) with ~24 orthogonal dimensions.

Why: Onboarding Joob + LINE BK exposed the binary as too rigid. Different partners have different combinations of: reserve %, NAV source, tranche structure, KYC requirements, fund_wallet ownership. The binary forced false bundles.

Result: Same Pool contract + LP token + flow for every partner. Configuration drives behavior.

Refs: Pool Models, DB Schema

v3-02 — Aset always mints LP (PLATFORM_ISSUED only) ✅ Decided

v3-02 — LP Issuance

Date: 2026-05-29

Decision: Aset's PlatformLPToken is the sole source of truth for investor positions. FUND_ISSUED mode deprecated.

Why: Lets us guarantee accurate portfolio data, redemption tracking, and yield distribution regardless of partner sophistication.

🔴 Correction (2026-08-05) — this line used to end "Partners are notified via API/webhook." No such notification exists. grep for aset-lp-mint / partner_endpoint across apps/infra, apps/web and apps/admin-web returns 0 hits, and nothing runs against a partner endpoint on deposit. The fund manager reads the platform instead: GET /deposits is fund-scoped, and deposit_confirmed_ops carries fundManagers in its audience. The push-to-partner concept is dropped, not pending — do not describe it to a partner as available or as coming. Full record: v3-115 · 17-changelog.

Refs: Smart Contracts, Core Concepts

v3-03 — Remove Receipt NFT; deposits are atomic ✅ Decided

v3-03 — No Receipt NFT

Date: 2026-05-29

Decision: Remove PlatformReceiptNFT contract. Deposit + LP mint + fund_wallet split in one transaction.

Why: Receipt NFT existed to bridge async LP issuance (FUND_ISSUED). With Aset always minting LP atomically, the bridge is unnecessary. Simplifies on-chain state and removes D+7 refund corner case.

Refs: Investment Lifecycle, Smart Contracts

v3-04 — PENDING_RESERVE replaces FM_ACCEPTED ✅ Decided

v3-04 — Redemption Partner Coordination

Date: 2026-05-29

Decision: Replace FM_ACCEPTED redemption status with PENDING_RESERVE. Single-stage admin approve; partner sends shortfall via Pool.fundRedemption().

Why: FM_ACCEPTED implied formal partner sign-off in the redemption flow. In practice, the only partner action that matters is sending money when the Pool reserve is insufficient — a state, not a workflow step.

Scope note (epoch): PENDING_RESERVE is instant-pool only. Epoch pools (redemption_epoch_days > 0, epoch redemption) replace the indefinite-wait state with pro-rata partial fill + rollover. FM_ACCEPTED removal was finalized in v3-34.

Refs: Redemption, Status Machines, epoch redemption

v3-05 — Two-level pool pause (soft + emergency) ✅ Decided

v3-05 — Pool Pause Granularity

Date: 2026-05-29

Decision: Add is_emergency_frozen flag separate from is_paused. Soft pause blocks deposits only; emergency freeze blocks all activity including redemptions.

Why: Original is_paused was overloaded — admin sometimes wanted to stop deposits without trapping investors, sometimes wanted to halt everything for security incidents. Splitting clarifies intent.

Refs: Status Machines, Pool Models

v3-06 — Emergency Wind-Down (60+30 day timelock) ✅ Decided

v3-06 — Wind-Down Recovery

Date: 2026-05-29

Decision: Add proposeWindDown() / executeWindDown() to PlatformPool. 60-day silence trigger + 30-day timelock = 90-day total to wind-down. After execution, LP holders claim pro-rata from Pool reserve.

Critical limitation: Only the Pool reserve (typically 10%) is recoverable on-chain. The 90% sitting in fund_wallet is partner-controlled — recovering it requires off-chain legal action.

Why: 90 days aligns with traditional finance "90 days past due" default trigger. The 30-day timelock gives partners final chance to respond and prevents accidental wind-down.

Refs: Pool Models → Emergency Wind-Down, Redemption

v3-07 — collateral_type enum → free-text + ratio ✅ Decided

v3-07 — Collateral Description

Date: 2026-05-29

Decision: Replace collateral_type enum (FULLY_COLLATERALIZED / PARTIALLY_COLLATERALIZED / UNSECURED) with collateral_description (TEXT) + optional collateral_ratio (NUMERIC).

Why: Real-world collateral arrangements don't fit three buckets. Free text + ratio captures nuance (e.g., "Receivables from invoice financing, 150% overcollateralized via legal lien").

Refs: Writedown & NAV, DB Schema

v3-08 — NAV data source dimension 🔄 Superseded by v3-15

v3-08 — NAV Responsibility (superseded 2026-06-02)

Date: 2026-05-29

Original decision (2026-05-29): Add nav_data_source dimension with two values — ASET_INTERNAL (Aset computes NAV) or PARTNER_REPORTED (partner pushes NAV).

Superseded by v3-15: Pure mirroring (PARTNER_REPORTED) dropped. All pools always go through Aset oracle, which fetches raw data from partner API and applies Aset's formula. Single processing path simplifies trust model + enables tranche waterfall enforcement (v3-14).

Refs: v3-15 for current model.

v3-18 — Minimal on-chain config + KYC compliance enforced on-chain ✅ Decided

v3-18 — On-Chain vs Off-Chain Data Boundary

Date: 2026-06-02

Decision: Adopt minimum on-chain config (Maple-style) with one exception — KYC compliance fields are enforced on-chain (Centrifuge-style for that subset only).

On-chain (PlatformPool — settlement + compliance):

  • navPerToken, reserveBalance — settlement state
  • lockupDays, maturityDate — redemption gating
  • penaltyRateBps, penaltyType, flatFeeAmount — early-exit penalty (in RedemptionConfig; contract computes + applies on-chain at requestRedemption). Corrects the off-chain classification of the v3-18 initial draft (see ⚠️ below)
  • paused, isEmergencyFrozen — admin pause flags
  • reserveBps — used by deposit() for 10/90 split (basis points)
  • fundWallet — destination for 90% release
  • kycContract — reference to PlatformKYCSoulbound
  • kycLevelRequired — enforces KYC vs KYB at deposit (v3-10 + v3-18)
  • kycJurisdictionWhitelist — enforces region restrictions at deposit (v3-10 + v3-18)

Off-chain (DB only):

  • apy_rate, apy_disclosure, description, collateral_description — display/marketing
  • min_investment, capacity — soft limits (enforced in app layer)
  • penalty_type, penalty_rate, penalty_fee_amountDB mirror only (penalty SoT is on-chain RedemptionConfig; contract computes payout). See the on-chain list above
  • tranche_group_id, tranche_role — Lambda applies waterfall (v3-14)
  • maturity_model, lifecycle_status (incl. IMPAIRED) — Lambda enforces state transitions
  • external_provider, external_fund_id, partner_id — integration metadata
  • investor_count, total_yield_distributed — derived from events
  • start_date, end_date — deploy/maturity timestamps

Never on-chain (privacy/PII):

  • Investor name, email, country (kept in DB + SumSub)
  • KYC documents (SumSub only)
  • Borrower-level loan data
  • Partner NDA/internal terms

Why minimum + KYC exception:

  • Our model is permissioned (KYC-gated platform), not pure DeFi — investors already trust the platform
  • Less contract surface = faster audit, easier upgrades, cheaper deploy
  • KYC level/jurisdiction is the ONE regulatory hard line — if not enforced on-chain, a sophisticated user could bypass our app and deposit into wrong pool (KYB-only, region-restricted) → VASP regulatory violation
  • All other fields can be enforced by Lambda (we already trust Lambda for NAV per v3-15, waterfall per v3-14). Penalty is the exception — computed/applied in the on-chain RedemptionConfig (SoT on-chain)

Accepted trust assumptions (Lambda authority):

  • Penalty/payout is computed on-chain (grossAmount − penaltyAmount, contract is SoT) — not a Lambda trust dependency
  • Lambda applies tranche waterfall correctly
  • Lambda enforces IMPAIRED/lifecycle gating before allowing on-chain calls
  • Lambda monitors for direct contract bypass attempts → admin can revoke SBT / pause pool

Mitigations against Lambda errors/compromise:

  • All NAV updates have 24h timelock (admin can cancel during window)
  • All fund_wallet/reserve/KYC changes have 7-day timelock
  • Wind-down has 30-day timelock
  • Multi-sig admin role for critical functions

⚠️ v3-18 correction (penalty SoT): The initial draft classified penalty_* as off-chain (Lambda computes payout), but since the RedemptionConfig contract (penaltyRateBps/penaltyType/flatFeeAmount) stores, computes, and applies the penalty on-chain, the penalty-calculation SoT is on-chain (confirmed). The DB penalty_* columns are a display mirror. (Related bug: YIELD_BASED ignored bps and forfeited the full yield → fixed to apply the rate, SECURITY_REVIEW P-1.)

⚠️ v3-18 timing for epoch pools: v3-18 fixes penalty/NAV at request time — this holds for instant pools only. Epoch pools (epoch redemption) compute NAV and penalty at settlement time (forward pricing); nav_at_request is NULL for epoch pools.

Refs: Smart Contracts, v3-10 (per-pool KYC), v3-14, v3-15, v3-16

v3-16 — Reserve consumption integrated into updateNAV (atomic; loss-absorption framing superseded by R8) ⚠️ Superseded in part — v3-109

v3-16 — Reserve Consumption Interface

⚠️ Superseded in part by R8 (v3-109). The premise below — that loss absorption depletes the reserve — is retired. The reserve is carved out of investor deposits, so it is already inside the claim NAV prices; debiting it against a loss double-counted. Reserve = redemption liquidity buffer only, and the backend passes reserveConsumed = 0 on every call (NO_RESERVE_CONSUMED, shipped). What still stands: the atomicity argument (one tx, one event, one timelock queue) and the ABI — the second argument is unchanged, only its value is fixed at 0. Kept for history — read this card for why the argument exists, not for what it does.

Date: 2026-06-02

Decision: Reserve depletion from loss absorption is integrated into updateNAV(), not exposed as a separate function. Lambda makes a single atomic call that updates NAV and consumes reserve in one transaction.

New signature:

solidity
function updateNAV(uint256 newNav, uint256 reserveConsumed)
    external onlyRole(ORACLE_ROLE)
{
    if (reserveConsumed > 0) {
        require(reserveBalance >= reserveConsumed, "Insufficient reserve");
        reserveBalance -= reserveConsumed;
    }
    if (newNav < navPerToken) {
        // 24h timelock for decreases (existing v3.0 mechanism)
        pendingNav = newNav;
        pendingReserveConsumed = reserveConsumed;
        navProposedAt = block.timestamp;
        emit NAVDecreaseProposed(newNav, reserveConsumed, block.timestamp + 24 hours);
    } else {
        navPerToken = newNav;
        emit NAVUpdated(newNav, reserveConsumed);
    }
}

Lambda flow:

typescript
const writeoffs = applyOJKSchedule(partnerData.loans); // v3-13
const reserveConsumed = Math.min(writeoffs.total, pool.reserveBalance);
const remainingLoss = writeoffs.total - reserveConsumed;
const newNav = applyTrancheWaterfall(pool, remainingLoss); // v3-14 if grouped
await pool.updateNAV(newNav, reserveConsumed); // single atomic call

Reserve state transitions (full inventory):

EventreserveBalance changeCaller
Deposit+10% of amountInvestor (deposit())
Loss recognized (writeoff ≤ reserve)-writeoffAmount, NAV unchangedAset Lambda (updateNAV() with reserveConsumed > 0)
Loss recognized (writeoff > reserve)→ 0, NAV drops by excessAset Lambda (updateNAV() with reserveConsumed = current reserveBalance)
Redemption (reserve sufficient)-payoutInvestor (redeem())
Redemption (reserve insufficient)reserve fully consumed, PENDING_RESERVE triggeredInvestor (redeem())
Partner top-up+shortfallPartner (fundRedemption())
Penalty fees (early redemption)+penaltyAmountInvestor (redeem() with penalty)

Why integrate vs separate function:

  • Atomic — no race window between reserve change and NAV change
  • Cleaner event log — one NAVUpdated(newNav, reserveConsumed) event captures the full state change
  • Timelock integration — NAV decrease + reserve consumption queue together for 24h timelock; both apply atomically on execute

Why NOT a standalone consumeReserveForLoss() function:

  • Two-call sequence (consume reserve → updateNAV) leaves a race window where a deposit/redemption could observe inconsistent state
  • Two separate events to correlate in audit trail
  • Lambda complexity ↑ (orchestration logic)

Edge case — admin manual reserve adjustment: For rare scenarios (e.g., legal settlement, manual rebalancing), admin can call updateNAV(currentNav, amount) with newNav = navPerToken to consume reserve without NAV change. Or add adminAdjustReserve(int256 delta, string memory reason) if needed — deferred to implementation phase.

⚠️ Superseded in part by v3-109 (R8). The atomicity argument above still holds and the ABI is unchanged, but reserve consumption is no longer a loss-absorption step — the backend passes reserveConsumed = 0 on every updateNAV. Read this card for why the argument exists, not for what it does.

Refs: Writedown & NAV, Smart Contracts → Core Functions, v3-13 (DPD auto-writeoff — historical: it once fed reserveConsumed, which is now always 0 under R8)

v3-17 — Cross-VASP KYC strategy (SumSub Reusable KYC when shared provider, else fast re-KYC) ✅ Decided

v3-17 — Cross-VASP KYC for Secondary Market Partnerships

Date: 2026-06-02

Decision: Adopt a two-track strategy for KYC when onboarding users from partner platforms (e.g., Tokocrypto secondary market):

Track 1 — Partner uses SumSub: Reusable KYC

  • Aset and partner conclude SumSub Reusable KYC agreement
  • User clicks "Sign up with my Tokocrypto verification"
  • Aset backend requests SumSub share token → consent modal → SumSub transfers verified applicant data
  • Aset SumSub re-runs recipient-level checks (jurisdiction whitelist, KYC level required, expiry, sanctions screening)
  • If passes: SBT mint on success (PlatformKYCSoulbound)
  • User-facing time: ~minutes

Track 2 — Partner does NOT use SumSub: Fast re-KYC

  • Aset runs its own full SumSub flow
  • UI hint: "Are you a verified Tokocrypto user? Verification will be faster."
  • Use SumSub returning-user flow (skip already-uploaded docs)
  • User provides incremental data only (jurisdiction confirm, US person attestation)
  • User-facing time: ~5-10 minutes (vs ~15 for cold KYC)

Why two tracks:

  • Track 1 minimum friction but depends on partner's KYC stack
  • Track 2 always works, fallback for partners using non-SumSub providers
  • Both legally compliant (recipient-level checks always re-run; sanctions screening always our responsibility)

Why NOT shortcut KYC entirely (e.g., trust partner's verification):

  • Regulators (MAS Singapore, OJK Indonesia, EU) require VASP to do its own KYC
  • Liability stays with each VASP regardless of upstream verification
  • Reusable KYC sidesteps this by re-running our checks on shared data — not by skipping checks

Why NOT Binance-style federation:

  • Binance forces fresh KYC at each regional entity. High user friction. Doesn't reuse work.
  • Reusable KYC (Track 1) gives same regulatory compliance with much lower friction.

Not adopted: Custom federated identity matching (bilateral API to compare hashed PII)

  • Bespoke per-partner integration. Not standard. Legal complexity per partnership.

Implementation Map:

PhaseWhenAction
Phase 1NowAset SumSub KYC running. UI hint for "existing user" path. No code change.
Phase 2Tokocrypto agreement signedCheck Tokocrypto KYC stack → SumSub: enable Reusable KYC partnership. Else: stay on Track 2.
Phase 3Multi-partner expansionReusable KYC supports unlimited partners — same infra for Binance.SG, Coinbase, etc.

Separate from but related: FATF Travel Rule for cross-VASP transaction data (IVMS101 standard via Notabene/Sygna). Required when funds move between Aset Pool and partner wallets above thresholds (Indonesia $1K, Singapore SGD 1.5K). Handled as separate operational integration, not v3-17 scope.

Refs: KYC & Identity, v3-10 (per-pool KYC gating)

v3-15 — Drop PARTNER_REPORTED; Aset always processes NAV ✅ Decided

v3-15 — Single NAV Processing Path (supersedes v3-08)

Date: 2026-05-29

Decision: Remove the nav_data_source dimension. All pools use the same model — Aset oracle ingests partner data via API (or admin input), runs Aset's formula (incl. OJK write-off schedule per v3-13, tranche waterfall per v3-14), and calls Pool.updateNAV().

Why:

  • Even when partners "report" NAV, Aset still adjudicates it (sanity check, write-off schedule, tranche waterfall). The "pure mirroring" pretense was misleading.
  • Tranche waterfall (v3-14) requires Aset to compute NAV per pool in a group. Mirroring would break the waterfall guarantee.
  • Single processing path = single trust model = simpler audit story.

Implementation:

  • Drop nav_data_source enum + column. Migration: deprecated marker, drop in 6 months.
  • All pools' NAV flows through the same Lambda pipeline.
  • Partner API integration (Joob eNote, LINE BK risk dashboard, future) is a data source for raw inputs only — not for the final NAV value.

What partners still control: Raw data (asset values, DPD, collateral status). What Aset controls: NAV computation formula, write-off rules, tranche waterfall, timelock.

Refs: Writedown & NAV → NAV Update Flow, v3-13 (OJK schedule), v3-14 (tranche waterfall)

v3-09 — Tranche structure as dimension 🔄 Superseded by v3-14

v3-09 — Tranche Structure (superseded 2026-06-02)

Date: 2026-05-29

Original decision (2026-05-29): Add tranche_structure dimension. SINGLE (default, one LP token). JUNIOR_SENIOR (two LP tokens — Junior absorbs first loss, Senior gets priority yield).

Superseded by v3-14: Single-pool-two-LP pattern adds contract complexity without buying enforcement (Aset Lambda controls NAV anyway under v3-15). Replaced with two separate SINGLE pools linked via tranche_group_id + tranche_role. Lambda applies waterfall during NAV computation.

Refs: v3-14 for current model.

v3-14 — Tranche via linked SINGLE pools (supersedes v3-09) ✅ Decided

v3-14 — Tranche via Pool Grouping

Date: 2026-06-02

Decision: Replace tranche_structure enum with two new pool columns: tranche_group_id (UUID, nullable) and tranche_role (ENUM: SENIOR | MEZZANINE | JUNIOR, nullable). Tranched products are modeled as 2-3 separate SINGLE pools sharing a tranche_group_id. Aset oracle Lambda applies loss waterfall when computing NAV for grouped pools (Junior absorbs first, Mezzanine next, Senior last).

Schema:

sql
ALTER TABLE pools ADD COLUMN tranche_group_id UUID NULL;
ALTER TABLE pools ADD COLUMN tranche_role TEXT NULL
  CHECK (tranche_role IN ('SENIOR', 'MEZZANINE', 'JUNIOR'));

-- Constraints
ALTER TABLE pools ADD CONSTRAINT pool_in_one_group UNIQUE (tranche_group_id, tranche_role);
-- Plus app-level check: max 3 pools per tranche_group_id (one per role)

Same-group constraints (enforced at Factory deploy + Admin UI):

  • fund_wallet — same partner controls underlying assets
  • operating_currency, chain_id, external_provider, maturity_days — must align
  • reserve_percentage, apy_rate, kyc_level_required, lockup_days, penalty_* — can differ per role

Lambda waterfall (NAV computation):

typescript
// For each group, on every NAV update:
const sortedByRole = group.sort(byPriority); // SENIOR first (absorbs last), JUNIOR last (absorbs first)
let remainingLoss = totalLossFromPartnerData;
for (const p of sortedByRole.reverse()) {
  // iterate Junior→Senior
  const absorbed = min(remainingLoss, p.capital);
  navs[p.id] = (p.capital - absorbed) / p.lpSupply;
  remainingLoss -= absorbed;
}

Junior depletion behavior (v3-12 integration): When a Junior tranche NAV hits 0, that pool auto-transitions to IMPAIRED (deposits paused). Partner can restore (top-up reserve or recover assets) → return to ACTIVE. Otherwise eventually → WIND_DOWN.

UI policy: Group-aware display — Frontend renders linked tranche pools as a single "Tranched Product" card with sub-tier breakdown. Standalone pools (no tranche_group_id) display as before.

Smart contract impact: Zero. PlatformPool and PlatformLPToken stay simple SINGLE pools. No 2-LP-per-pool logic. No on-chain waterfall code. Waterfall is enforced via Lambda (v3-15 made this possible).

Why this is better than v3-09:

  • Contract code 30% smaller (no tranche branching)
  • Audit surface smaller (waterfall in our Lambda we control, not partner-NAV-dependent)
  • Same protection guarantee as v3-09 (since v3-15 means we control NAV anyway)
  • Easier to model 3-tier (SENIOR/MEZZANINE/JUNIOR) than retrofitting JUNIOR_SENIOR enum

Refs: Pool Models → Tranche Group, Writedown & NAV, v3-15 (single NAV path), v3-12 (IMPAIRED state)

v3-10 — Per-pool KYC gating (level + jurisdiction whitelist) ✅ Decided

v3-10 — KYC Per-Pool Gating

Date: 2026-05-29

Decision: Add kyc_level_required (KYC/KYB) and kyc_jurisdiction_whitelist (TEXT[] of ISO country codes) dimensions. Pool contract validates investor SBT attributes at deposit.

Why: Different pools have different regulatory exposure. Institutional-only pools need KYB. Region-restricted pools need jurisdiction whitelist. Hard-coding at platform level wouldn't scale.

Refs: KYC & Identity → Per-Pool KYC Gating

v3-19 — KYC validity / expiry policy (expiry blocks deposit, not redemption) ✅ Decided

v3-19 — KYC Validity & Expiry Policy

Date: 2026-06-12

Decision: The KYC SBT carries a validity period we (the VASP) set — SumSub imposes no fixed expiry; it only auto-expires the underlying ID document (reads the document's printed expiry). Default validity 1 year, risk-tiered (High 1y / Medium 2y / Low 3y), capped at the ID document's expiry. Re-verification is prompted at T-30 days via SumSub periodic verification (renew/extend the SBT, not full re-onboarding).

Three independent states (not one boolean):

  • valid — deposit + redeem
  • expired (validity lapsed) — blocks new deposits; redemption of own funds stays open; triggers re-KYC
  • revoked (sanctions / fraud / issuer action) — separate flag; may block redemption (reflects an actual risk finding). Driven by ongoing AML monitoring, independent of the expiry clock.

Why: "Gate value in, never trap value out." Blocking redemption of one's own funds purely because KYC expired is an industry outlier and a consumer-protection / fund-trap risk (acute during WIND_DOWN). Regulators (FATF Rec.10, FinCEN, MAS) require risk-based ongoing CDD — not freezing assets on a calendar lapse (FinCEN is explicitly event-driven). On-chain peers (ERC-3643, Coinbase Verifications, Securitize, Centrifuge) gate entry/transfer-in and rely on revocation + document-expiry triggers, not a fixed validity clock that traps holders. Norm refresh cadences (High ~1y / Med ~3y / Low ~5y) are convention, not legal mandate — so a long-dated SBT used as the sole freshness signal would conflict with ongoing-CDD duties.

Resolves: SECURITY_REVIEW R-3 (WIND_DOWN KYC-expiry fund trap).

Backend/contract work:

  • requestRedemption KYC check: split expired (allow exit) vs revoked (block); WIND_DOWN/terminal states bypass the expiry check for non-revoked holders
  • SBT mint: risk-tiered validity (1/2/3y), cap at document expiry
  • SumSub periodic-verification rule at T-30d; keep ongoing AML monitoring driving revoked

Refs: v3-10 (per-pool KYC), v3-18 (on-chain KYC enforcement), KYC & Identity

v3-20 — Yield = Manual Claim only (drop AUTO distribution + `yield_trigger`) ✅ Decided

v3-20 — Yield Distribution: Manual Claim Only

Date: 2026-06-12

Decision: All pools use claim-based, manual-trigger yield (yield_trigger = MANUAL). The investor signs claimYield() (or reinvest()) themselves. The AUTO / scheduled-distribution option is removed — it had no working contract path and survived only as a v2.x remnant in pools.yield_trigger + the admin UI.

Why:

  • The v3.0 contract is inherently claim-based: depositYield (partner) → distributeYield (Lambda marks pro-rata) → claimYield (investor pulls). No auto-push path ever existed on-chain.
  • Per BD1 (Manual Claim): principal and yield stay separate, LP price stays at par $1.00, investor pulls on their own signature.
  • Reinforces non-custodial design — the investor signs their own deposit, redemption request, and yield claim (see 09-rbac).

Cleanup (2026-06-12):

  • yield_trigger removed from admin UI (pool create/edit, pool-detail config + yield-settings tabs, yield-distribution "Trigger" column); legacy AUTO rows coerced to MANUAL.
  • DB migration 0010_drop_yield_trigger drops pools.yield_trigger; pools create/update Lambdas no longer reference it. No contract impact.

Still live: distribution cadence lives in yield_frequency (public commitment); next date shown via Estimated → Funded D-day. Estimated yield amount formula is a separate open track.

Secondary-market caveat: listing LP on an exchange (e.g. Tokocrypto) reopens whether to keep claim vs accruing / rebasing yield (in-exchange omnibus holders can't claim). Tracked as a separate Open Decision — does not change v3-20 for direct holders.

Refs: BD1 (Yield Distribution Model), 17-changelog v3-20, 11-db-schema (yield_trigger dropped), 04-pool-models, 02-core-concepts

v3-25 — Pool Risk Tier badge (Low/Med/High), receivables-first ✅ Decided (Phase 1)

v3-25 — Pool Card Risk Tier

Date: 2026-06-16 (Phase 1)

Decision: Replace the deprecated collateral_type badge (v3-07) with an auto-computed Low / Med / High risk tier shown on the pool card + detail (investor and admin). Tier is derived from pool state at render — never admin-selected.

Model (receivables-first): Our pools are private-credit / receivables (par ~100%), so — like Maple / Goldfinch / Centrifuge — we tier by structure (tranche) + portfolio health, NOT collateral ratio. collateral_ratio is demoted; FX / operating_currency is excluded (operator-managed); NPL is not used directly (already embedded in nav_per_token = (subscribed − npl)/subscribed).

Precedence High → Med → Low. High: IMPAIRED/WIND_DOWN · is_emergency_frozen · tranche_role=JUNIOR · nav<0.90 · NPL>5%. Medium: nav 0.90–0.999 · SENIOR · NPL 2–5% or rising DPD30/60 · reserve_percentage<10%. Else Low. Thresholds confirmed 2026-06-16 (NPL <2/2–5/>5%, reserve 10%, NAV <0.90/0.90–0.999). Cadence: event-driven immediate for lifecycle/freeze/NAV; monthly for NPL/DPD; hysteresis to avoid flapping.

Phasing:

  • Phase 1 (implemented): lifecycle_status + is_emergency_frozen + nav_per_token + reserve_percentage.
  • Phase 2 (⏳ pending): tranche_role (v3-14, FE not wired) + partner NPL/DPD — depends on the Joob API 2nd data request (DPD ratio denominator outstanding_principal; concentration/tenor/utilization optional). Pools without an external fund-data provider use reserve + lifecycle + nav only.

Pending dependency: Joob 2nd-round API (NPL/DPD denominator + optional concentration/tenor/utilization), and total_rni gross/net clarification. Equity Buffer (partner absorbs NPL ≤5%) has no dedicated DB field today — proper support implies on-chain operator first-loss logic (next cycle); display-only until then.

Refs: Pool Models → Risk Levels, v3-07 (collateral_type→description), v3-12 (impairment/wind-down), v3-14 (tranche), Grab Joob API — Aset Integration (RNI/NPL, Equity Buffer)

v3-26 — fund_id required on every pool (no Aset-custody direct pools) ✅ Decided

v3-26 — Every Pool Is Fund-Linked

Date: 2026-06-16

Decision: Every pool must have a fund_id (linked to a partner fund). The previously-documented Aset-direct pool config (fund_wallet = Aset treasury, no fund_id) is removed — Aset does not operate custody pools. Build target is the invest-through-Aset (full on-chain) model, not the Joob read-only hybrid.

Why: In a direct pool, 90% of each deposit routes to fund_wallet = Aset treasury → Aset would hold investor funds → custodial (breaks the non-custody / VASP-avoidance posture). Requiring a partner fund keeps the 90% in a partner-controlled fund_wallet.

Enforcement (product layer): pools.post.create rejects a missing fund_id; admin pool-create requires fund selection. Legacy fundId ? FUND_POOL : AS_POOL branches collapse to the fund path (no-fund_id branch becomes dead code — harmless, clean up later). DB pools.fund_id NOT NULL is deferred until existing no-fund_id rows are cleaned.

⚠️ Scope: this is a product-level constraint, NOT the non-custody guarantee itself. Non-custody is determined at the contract layer — whether Aset can unilaterally redirect fund_wallet / upgrade the money-path to itself. The real guarantees (immutable money-path, independent multi-sig + timelock for exit, fund_wallet ≠ treasury) live in the contract + legal track (see Custody vs Non-Custody), not in form/backend validation. Final non-custody call needs Legal.

Contract impact: none — fund_id is off-chain only.

Refs: Pool Models → fund_wallet, Core Concepts, Custody vs Non-Custody (Notion), v3-01 (pool_type→dimensions)

v3-27 — Non-custody key governance: money-path non-upgradeable, periphery timelocked ✅ Decided

v3-27 — Money-Path Non-Upgradeable

Date: 2026-06-17

Decision: The user-funds path — redemption payout = request.investor (the LP-locked verified holder set at request time; no destination param), yield = current holder, withdrawFees bounded by accumulated fees → fixed treasury — is locked as non-upgradeable. Any contract upgrade (if introduced later) targets only the periphery (oracle address / fee computation, etc.) and goes through a timelock. Any "investor payout address"-type feature that an operator setter could change is excluded from the outset.

Why: A custody determination is based on the "worst state an operator can reach unilaterally." With unilateral, instant upgrade authority, the operator could deploy a new implementation that changes the destination, making the "fixed destination" meaningless. By excluding the money-path from the upgrade scope, even if we hold modification rights, we cannot unilaterally change the destination of user funds — so we are not classified as Custodial.

Current code alignment: The current contracts are already non-upgradeablePlatformPool/LPToken use Clones (EIP-1167, fixed implementation), the Factory implementation pointer is immutable, and there is no _authorizeUpgrade. Payout is fixed to request.investor, and withdrawFees is bounded by unclaimedYield. In other words, this decision codifies the current design; even if we move to an upgradeable model later, the money-path stays an immutable core. No multi-sig needed (a single cold key + timelock is sufficient).

Work request: Claude-Plan/Active/non-custodial-money-path-implementation.md (verification, documentation, trade-offs).

Refs: 09-rbac → Key custody, Custody vs Non-Custody (Notion), apps/contract/src/PlatformPool*.sol

v3-28 — Emergency freeze exit-right: time-bound + redeem fallback (Option D) ✅ Decided · 🛠 Implemented

v3-28 — Emergency Freeze Must Not Trap

⚠️ Superseded in a second part: the permissionless redeem fallback is withdrawn — v3-157. The redeem fallback half of Option D was claimRedemptionFallback, and it drew on a reserve that now sits outside the pool, so it was removed 2026-08-31. The freeze half of this card stands — the asymmetric gate, the 72h exit window and the 7d auto-expiry are live. What changed is that a frozen or unanswered instant request is no longer settleable by anybody after 7 days; it waits on the reserve wallet.

Date: 2026-06-17 · Implemented: 2026-06-22 (contract landed via ch/product merge)

Decision (Option D): is_emergency_frozen is triggered instantly (for emergency response) but must not trap funds indefinitely.

  • A full freeze that also blocks exit (redemption) lasts at most 72 hours (3 days) → after that, it relaxes to automatically allowing redemption (redeem/claim)
  • Other freeze windows default to 7 days (consistent with Aset Admin timelocks / redemption epoch)
  • Extension and unfreeze cannot be done instantly and unilaterally → must go through governance/timelock (asymmetric: freezing is fast, recovery/extension is slow)

Why: The current freeze blocks deposits/withdrawals/claims entirely + is instant and unilateral via PAUSER + has no time limit → if indefinite, it risks violating the investor exit right (Custody criterion #5). Industry practice (Compound) never blocks Redeem/Repay (exit) even during a pause, and unpause is governance-only. This decision reflects that principle (temporary, asymmetric, exit guaranteed) with 72h/7-day bounds.

Implementation impact (contract): Add a max-duration auto-expiry, or a timelocked redeem fallback after N days, to emergencyFreeze/unfreeze (currently instant and unlimited). (PlatformPool.sol PAUSER path)

✅ Implemented (code-verified 2026-06-22, PlatformPool.sol post ch/product merge): emergencyFreeze() records freezeStartedAt. Asymmetric gate (_isFreezeActive = flagged AND within FREEZE_MAX_DURATION): capital-IN (deposit/depositYield/reinvest) blocked for the whole freeze; value-OUT (requestRedemption/claimRedemption/claimYield/executeEpoch/fundRedemption/approve/fallback) auto-unblocks after FREEZE_EXIT_WINDOW = 72h; whole freeze auto-expires after FREEZE_MAX_DURATION = 7d. Lawful prolongation via proposeFreezeExtend/executeFreezeExtend/cancelFreezeExtend (DEFAULT_ADMIN + governance timelock; pushes freezeStartedAt forward). unfreeze stays PAUSER (recovery fast). DB mirror: migration 0023 pools.freeze_started_at + pool responses expose freeze_started_at/freeze_exit_window_ends_at/freeze_auto_expires_at. (Note: an earlier same-day audit flagged this as unimplemented — that was against the pre-merge branch state; the ch/product contract commit landed it. See 17-changelog 2026-06-22.)

⚠️ Superseded in one part: the FreezeExtend triple was later removed. Its 7-day governance timelock equalled the 7-day freeze lifetime, so a prolongation could only become executable after the freeze had already lapsed — executing it then either did nothing or retroactively re-locked the pool and restarted the 72h exit block on investors who had regained withdrawal rights, which is the exact harm v3-28 exists to prevent. proposeFreezeExtend / executeFreezeExtend / cancelFreezeExtend no longer exist (GovernanceLib has no such function and they are absent from the ABI); only the pendingFreezeExtend* storage slots remain, cleared on unfreeze, and POST /pools/{id}/freeze returns 410 for the extend actions. Everything else in this card stands — the asymmetric gate, the 72h exit window and the 7d auto-expiry are live. To escalate past 7 days use pause or impairment, both of which leave exits open.

Refs: 08 → Pauser/Admin functions + safety events, 09-rbac → Key custody, 09a → Exit right, 15 → /pools/{id}/freeze, Custody vs Non-Custody (#5 Exit Right), Decision Log "is_emergency_frozen freeze fallback" (Notion), Compound v2 Governance

v3-29 — DB↔on-chain field sync & PATCH enforcement ✅ Decided

v3-29 — On-chain Value Sync

Date: 2026-06-18

Decision: Pool-config fields fall into three classes; pools.patch.update.ts enforces sync/locking accordingly:

  • A (on-chain setter exists): is_paused → PATCH must call on-chain pause()/unpause() (both instant). DB-only is a security gap (investor can bypass via a direct deposit(), which is whenNotPaused-gated). Highest priority.
  • B (on-chain immutable — no setter): capacity, min/max_investment, penalty_type/rate/fee, redemption_type, notice_period, lockup/maturity, accepted_currencies → PATCH rejected after deploy (read-only); DRAFT freely editable. No setters added (EIP-170 near limit + these are locked-after-ACTIVE investor terms per the 04 matrix).
  • C (off-chain only): name, apy, yield_frequency, collateral → DB-only is correct. investment_blocked → DEPRECATED: redundant with on-chain is_paused (now wired). Removed from invest-eligibility (now ACTIVE && !is_paused); DB column deprecated (drop later).

Why: Current PATCH updates DB only → DB↔on-chain divergence. For Class A this is a real security bypass; for Class B a silent display/behavior mismatch. Blocking Class-B PATCH enforces the existing Editability-After-ACTIVE decision with zero contract change; wiring is_paused (A) closes the bypass.

Implementation: wire is_pausedpause()/unpause(); reject Class-B PATCH on deployed pools; keep Class-C DB-only. (pools.patch.update.ts)

Refs: 08 → Field Sync, 04 → Editability After ACTIVE, ch ONCHAIN_VALUE_SYNC_DECISION.md

v3-30 — Targeted modularization: KYC-only upgradeable proxy, core immutable ✅ Decided · 🛠 Implemented

v3-30 — Targeted Upgradeability (KYC-only)

Date: 2026-06-18 · Implemented: 2026-06-19

Decision: The core money-path stays immutable (current Clones). Only the KYC contract (PlatformKYCSoulbound) becomes an upgradeable proxy (UUPS) + timelock, implemented before mainnet (Sept). Everything else is already updatable without an on-chain upgrade: stablecoins via existing add/removeStablecoin setters; oracle source / fee computation / validation are off-chain (Lambda); updateNAV bounds live in the immutable core.

Why: Fully-immutable Clones means any on-chain code change = redeploy + migrate every pool (high cost, existing pools not patchable). The only on-chain piece that genuinely evolves is KYC/compliance → make just that upgradeable (proxy + timelock) so compliance can change without pool migration, while the money-path stays non-custodial. Invariants: the periphery (KYC) may gate eligibility but can never move funds or change a payout destination; safety bounds stay in core; the KYC upgrade is timelocked (exit window, #5).

⚠️ Caveat (legal/audit): KYC gates redemption for REVOKED (AML-intended). An upgradeable KYC could alter that gating → a mild exit-right (#5) consideration. Mitigated by timelock (exit window) + the invariant (eligibility-only, no fund movement). To be confirmed by legal/audit.

Timing: pre-mainnet — restructure before audit while testnet has no live funds.

Implementation impact (contract): convert PlatformKYCSoulbound to a UUPS proxy behind a timelock; pools reference the stable proxy address. Core contracts unchanged.

Implemented (2026-06-19, PlatformKYCSoulbound.sol): converted to an ERC1967 UUPS upgradeable contract (Initializable + UUPSUpgradeable) with a 7-day upgrade timelock (UPGRADE_TIMELOCK = 7 days): proposeUpgrade(newImpl) → 7d → executeUpgrade() (or cancelUpgrade()). _authorizeUpgrade accepts only the timelocked path for the exact pending implementation (a direct UUPS upgrade reverts). New dep openzeppelin-contracts-upgradeable; events UpgradeProposed/UpgradeExecuted/UpgradeCancelled. Money-path (PlatformPool/PlatformLPToken) stays immutable Clones. Pools reference the KYC proxy address. Deployment note: existing testnet SBTs are greenfield — re-minted under the new proxy (no on-chain migration of old tokens). See the KYC-proxy deployment procedure in apps/contract/sepolia.md.

⚠️ Superseded in part (2026-08-14): UPGRADE_TIMELOCK is now 0 — the 7-day wait described above is gone, adopted from the proposal in v3-71. Everything else here still holds: the propose/execute pair, the _authorizeUpgrade gate against a direct upgradeToAndCall, and the three events.

Refs: 08 → PlatformKYCSoulbound, 09a → Upgrade governance, 09-rbac, v3-27

v3-31 — Exit-right fallback: permissionless timelocked redemption claim ✅ Decided · 🛠 Implemented

v3-31 — Permissionless Timelocked Exit (Custody #5)

⚠️ Superseded in half — v3-157. The instant-pool leg (claimRedemptionFallback) was removed 2026-08-31: v3-155 moved the reserve out of the pool, so the balance it paid from is structurally zero. The epoch leg below stands — permissionless executeEpoch and claim-on-behalf are live. Read this card as half-implemented, not implemented.

Date: 2026-06-18 · Implemented: 2026-06-19

Decision: Normal redemption settles via operator-triggered approveRedemption (ORACLE gate) — fast, and able to hold AML/anomalous requests. To satisfy non-custody #5 (exit right), add a permissionless timelocked fallback: if a redemption request stays unsettled > N days (notice + grace) AND the pool reserve holds funds, any investor can self-execute claimRedemption (pro-rata against available reserve, at the last-applied NAV) without ORACLE approval. The fallback respects the AML / KYC-REVOKED gate (else a sanctioned wallet could wait out the timelock and bypass a hold).

approveRedemption is re-framed: not an "exit gate" but a "speed + AML-hold layer." Exit itself is guaranteed by the fallback.

Why: If exit depends entirely on operator discretion, the operator holds unilateral control over whether users can exit → leans custodial. WIND_DOWN-only exit is insufficient — triggering WIND_DOWN is itself an operator action (circular dependency). Liquidity is genuinely constrained (credit fund), so the fallback pays out only available reserve; the illiquid portion still waits for epoch funding (legitimate, not censorship). Benchmark: Maple / Goldfinch allow permissionless withdrawal against available liquidity.

Implementation impact (contract): make executeEpoch() permissionless after the deadline + claimRedemption(requestId) permissionless (claim-on-behalf, payout fixed to the original requester), both gated by NOT AML/KYC-REVOKED. Pro-rata is computed on-chain via the epoch fillRatio + cumulative indices (G/H) — see epoch redemption.

Implemented (2026-06-19) — instant + epoch:

  • Instant pools — new claimRedemptionFallback(requestId) (PlatformPool.sol): permissionless; when an instant-pool request is unsettled for FALLBACK_NOTICE_DAYS = 7 days, the reserve can cover it, and KYC is not revoked, anyone may trigger the payout — funds always go to the FIXED original investor (no destination param). Guarantees exit even if the operator never calls approveRedemption. Emits RedemptionFallbackClaimed. Backend: POST /redemption-requests/{id}/claim-fallback.
  • Epoch pools — already covered: executeEpoch is permissionless after the deadline and claimRedemption is claim-on-behalf (payout fixed to the requester). The fallback path is instant-pool-only because epoch exits never depend on operator discretion.

Refs: 08 → claimRedemptionFallback, 15 → /claim-fallback, epoch redemption, v3-28 freeze exit, 09a → Exit right, Custody vs Non-Custody (#5)

v3-32 — updateNAV bounds in core + role-admin cold-key/timelock (no multisig) ✅ Decided · 🛠 Implemented

v3-32 — NAV Bounds in Core & Role-Admin Acceptance

Date: 2026-06-18 · Implemented: 2026-06-22 (contract landed via ch/product merge)

Decision (NAV bounds): Implement a deviation cap + circuit breaker (+ staleness check) inside updateNAV in the immutable core, before mainnet. Because updateNAV is hot-key-called (Service Key), the bound must live in core so even a compromised ORACLE_ROLE cannot move NAV beyond a small bound per period. Closes the v3-29 open item. ✅ Implementation (code-verified 2026-06-22, PlatformPool.sol): updateNAV now calls _checkNavBound(newNav) (after the ≤ 1.0 clamp, on both the immediate-increase and queued-decrease paths) → reverts on circuitBreakerTripped and, when navDeviationCapBps > 0, when |newNav − navPerToken| exceeds the cap in either direction. So the hot-key write is now bounded in core (default cap 0 = disabled until configured). executeEpoch carries the same gate plus a staleness check (navStalenessSeconds) for the epoch-settle NAV. (An earlier same-day audit flagged updateNAV as still unbounded — that was the pre-merge branch state; the ch/product commit added _checkNavBound.)

Decision (role-admin griefing): DEFAULT_ADMIN_ROLE (cold key) can grant arbitrary roles (e.g., self-grant ORACLE → NAV griefing). Accepted with a single cold key + timelock, no multisig — role grants already run under the Admin 7/30-day timelock (09-rbac), money-path immutability blocks theft, the NAV deviation cap bounds griefing, and the timelock makes any escalation visible + gives an exit window (v3-31 fallback). A 2-of-3 cold multisig stays available as optional defense-in-depth.

Why: Bounding the function neutralizes NAV griefing at the source, regardless of who holds the role — a cleaner fix than restricting role-granting. With theft off the table (immutable money-path) and exit guaranteed (v3-31), the residual cold-key risk is acceptable under timelock.

Implementation impact (contract): add deviation cap (e.g., ±X% per update) + cumulative circuit breaker + staleness to updateNAV; verify EIP-170 headroom (FOUNDRY_PROFILE=prod forge build --sizes).

Implemented (2026-06-19, PlatformPool.sol): updateNAV now reverts when circuitBreakerTripped, and — when navDeviationCapBps > 0 — when |newNav − navPerToken| exceeds the cap in either direction (default cap 0 = disabled). Admin setters setNavDeviationCap / tripCircuitBreaker / resetCircuitBreaker (events NavDeviationCapSet / CircuitBreakerTripped / CircuitBreakerReset); the breaker also gates executeEpoch. The bound lives in the immutable core (not Lambda), so even a compromised hot ORACLE key — or a self-granted ORACLE role via the cold key — cannot move NAV beyond the bound. This closes the v3-29 open item (and corrects the 09-rbac safeguard table that previously implied the bound already existed). Role-admin griefing is therefore accepted with a single cold key + 7/30-day timelock — no multisig (theft blocked by immutable money-path, griefing bounded at the source, escalation visible + exit-windowed via v3-31).

Refs: 08 → updateNAV / NAV bounds, 09-rbac → Key custody, v3-29, Custody vs Non-Custody (#1 unilateral control)

v3-26 (epoch) — Epoch-based redemption: pro-rata + forward pricing + pull claim ✅ Decided

v3-26 — Epoch Redemption

Date: 2026-06-18 (confirmed) · dev-impl decisions 2026-06-19

ℹ️ Numbering note: this is the Notion v3-26 (epoch redemption) decision. In this vitepress log the id v3-26 was already used for "every pool is fund-linked" — they are distinct decisions sharing a Notion number. Cross-references to "v3-26 epoch redemption" point here.

Decision: Pools with redemption_epoch_days > 0 (REVOLVING / open-ended) process exits via epoch windows instead of instant FIFO. At each window end, redemptions settle pro-rata against available liquidity, priced at the settlement-time NAV (forward pricing, SEC Rule 22c-1 style), with the unfilled remainder rolled over to the next epoch. Settlement (executeEpoch) is O(1) and computes only ratios/NAV; investors pull their payout via claimRedemption (same accumulator pattern as claimYield). Instant pools (= 0, FIXED_TERM) are unchanged. Deposits stay instant/atomic (one-directional epoch).

Why: Instant FIFO + indefinite PENDING_RESERVE breaks down under concurrent exits on a no-maturity pool: first-come unfairness / bank-run, request-time NAV arbitrage, indefinite waiting. Epoch batching + pro-rata + forward pricing is the Maple / Goldfinch / Centrifuge standard. Pull settlement avoids the block-gas-limit / DoS / cost-fairness problems of push loops.

Hybrid approval: epochs settle 100% automatically; admin only holdRequest/releaseRequests anomalies (large redemption ≥25% TVL, NAV deviation/staleness/breaker, compliance flags, partner distress, chronic low-fill). Thresholds are config (% and absolute floor + early-pool grace).

Confirmed implementation decisions (2026-06-19):

  • Yield accrual = (b) increment settlement — locked LP keeps accruing; at request, snapshot the accrual cursor; at burn, settle the delta. ⚠️ Reversed by v3-91 Rule 1, then restored in substance by v3-131 (3) (shipped 2026-08-20) — with the stop moved from burn to SETTLEMENT. Settling at the burn is what let an investor be paid for delaying their own claim, which is the half of Rule 1's diagnosis that held. The accumulatedYieldPerShare/yieldDebt pair this bullet named no longer exists; the cursor is an accrual index.
  • Enforcement lever = ① the partner-remainder hold-back (⚠️ a dormant lever — see there for what that means and for why "the 90%" is the wrong name for the remainder) — while redemptions are unmet, new deposits' partner remainder is held in a separate heldFundReleases bucket (never in reserveBalance → no NAV distortion) and used as redemption liquidity. Centrifuge-style "no new financing while redemptions pending." ⚠️ Corrected: this bullet said "auto-released when backlog clears". There is no auto-releasesetFundingRestricted(false) runs _releaseHeldFunds once in that transaction, nothing re-attempts it, and the release is clamped to what is genuinely free (physicalBalance − reserveBalance − redemptionCommitted − unclaimedYield) with the remainder staying in the bucket (v3-100). Also unbuilt in product: setFundingRestrictedOnChain has no caller in any lambda or admin screen, so the lever is reachable only by a direct contract call. Bucket semantics: 23-money-path → Reserve and hold-back.
  • Rollover = (2) Goldfinch epoch-cumulative — global indices G (surviving-fraction product) and H (per-unit cumulative payout) make a request claimable in O(1) regardless of how many epochs it spans; aggregate rollover happens in executeEpoch, lazy claim reads the indices.

Partner funding (3-layer): on-chain hard deadline + EpochFundingNeeded event (trustless partner keeper) · off-chain Lambda alerts (D-2/D-12h, notification_logs) · the 90% hold-back enforcement lever.

Contract impact: new state (epochDurationDays, currentEpochId/currentEpochEndsAt, epochTotalDemandLp/epochG/epochH/epochSettleNav, fundingRestricted/heldFundReleases); RedemptionRequest += epochId/principalLp/lpRemaining/yieldAccSnapshot; new fns executeEpoch/claimRedemption/holdRequest/releaseRequest/setFundingRestricted; new statuses QUEUED/PARTIALLY_FILLED; events EpochSettled/RedemptionClaimed/RedemptionRolledOver/EpochFundingNeeded. ⚠️ EIP-170: inline implementation accepted as over-limit (library refactor is a follow-up before mainnet). DB: pools.redemption_epoch_days, redemption_requests.{epoch_id,lp_filled,settled_nav}, new redemption_epochs table.

Open (non-PM): dev/audit (G·H precision, rollover ↔ G·H arithmetic consistency, yield double-accrual boundary) · ⚖️ legal ("confirmed-but-unclaimed" custody + escheatment, [Legal A-1]).

Refs: Redemption → Epoch-Based, Pool Models → redemption_epoch_days, Smart Contracts, Status Machines, v3-18 (revised for epoch), v3-04 (PENDING_RESERVE instant-only), v3-31, v3-32, v3-34. Benchmarks: Centrifuge Epochs · Goldfinch withdrawals · Maple cycles · SEC Rule 22c-1.

v3-33 — Managed (embedded) wallet allowed, with "Aset holds zero key shares" constraint ✅ Decided

v3-33 — Managed Wallet, Aset Share = 0

Date: 2026-06-18

Decision: Since the platform targets general (non-crypto-native) investors, a managed / embedded wallet option is allowed (email/social login, no seed phrase) in addition to self-custody. To preserve non-custody, the binding constraint is Aset holds zero key shares — Aset never holds any MPC key share that could move user funds (alone or in collusion). Implementation must use a third-party non-custodial embedded-wallet provider (Privy / Dynamic / Web3Auth / Magic); the user always controls signing client-side. The "user signs their own deposit/redeem/claim" premise is preserved → not a VASP/custodian.

⚠️ Custody trigger to avoid: if a managed wallet is structured so Aset stores / reshares a key share (the "B-plan Dynamic Resharing" variant where Aset holds shares), that creates unilateral control (#1) → custodial. Adopting that form requires a full 6-criteria re-test + VASP/custody licensing review (legal).

Why: The entire non-custody architecture assumes the user signs with their own key. A provider-managed wallet where Aset holds no share keeps that assumption intact while giving general investors a frictionless onboarding. Reputable embedded-wallet providers are themselves non-custodial (provider can't sign alone), so neither the provider nor Aset can unilaterally move funds.

Refs: 09a → Non-custodial test, Non-Custodial checklist §6.3 (Notion), Custody vs Non-Custody (#1)

v3-34 — Finalize FM_ACCEPTED removal (v3-04 follow-through) ✅ Decided

v3-34 — Remove FM_ACCEPTED Redemption Stage

Date: 2026-06-18

Decision: The redemption FM_ACCEPTED stage is removed, finalizing v3-04 (which was only half-applied — the stage stayed alive in code/seed/docs). Redemption flow simplifies to REQUESTED → RECOMMENDED → APPROVED → … (epoch pools use the epoch/rollover flow). The FM has no pre-acknowledge step in redemption; the partner's only meaningful action is funding a reserve shortfall — a state (PENDING_RESERVE / rollover), not a workflow step.

Why: v3-26 removed FM approval from redemption entirely, so the half-applied v3-04 left FM_ACCEPTED contradicting itself across migration (marked deprecated) vs enum/endpoint/seed/badge/CLAUDE.md (still live). Confirmed: a Fund-pool FM acknowledge step is genuinely unnecessary.

Cleanup scope (dev): remove FM_ACCEPTED enum value · POST /redemption-requests/{id}/fm-accept endpoint · seed rows · status-badge entry · fm_accepted_at (already deprecated in migration 0002) · update CLAUDE.md redemption flow · 07-redemption / 10-status-machines / 12-triggers / 15-api-reference / 18-failure docs · @aset/types RedemptionStatus union.

Status (2026-06-19): enum/docs/types done (redemption_status final = REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED); FE badge cleanup tracked separately. Note RECOMMENDED/APPROVED/CANCELLED are flow/on-chain concepts, not stored DB enum values (10-status-machines).

Refs: v3-04, epoch redemption decision, §12.1 (Epoch page, Notion)

v3-36 — Per-fund stats drill-down + shortfall KPI ✅ Decided · 🛠 FE done, BE deploy pending

v3-36 — Per-Fund Stats & Shortfall KPI

Date: 2026-06-22

Decision (per-fund stats): Admin manages many partner funds but had no per-fund aggregate (only platform-wide or per-pool). Add a "Fund Stats" section on fund-detail (TVL / Yield / Investors / Pending Redemptions / Awaiting Funding / Pending Yield) via GET /dashboard/stats?fund_id=admin = any fund, FM = own fund only (server validates ownership → 403). The dashboard stays aggregate (admin = platform-wide, FM = own-fund sum); no dashboard fund selector (chose fund-detail drill-down over a selector — "Option B"). Reuses the existing stats route (no new infra).

Decision (shortfall KPI): Surface PENDING_RESERVE redemptions — approved but the reserve can't cover the payout, so the partner must top up (fundRedemption) — as alerts.pending_reserve. Shown as a dashboard "Awaiting Partner Funding" action item (→ /redemptions?tab=funding) + fund-detail "Awaiting Funding" card (amber > 0). Count only (current snapshot, not a period total; redemption-only — yield has no shortfall, only yield_overdue).

  • Amount ($) deferred: the on-chain shortfall (RedemptionPendingReserve / EpochFundingNeeded, already the post-reserve deficit payout − reserve) should be persisted by the indexer into the dormant redemption_requests.funding_shortfall column → then the $ amount is a simple SUM, no RPC / no recompute. Requested to backend dev (Notion v3-26 §15); the on-chain readEpochShortfall RPC and re-deriving reserve math were rejected as costlier/duplicative.

Also (FM read-only guards, same day): hide "Create Distribution" on the yield page for FM; make the pool Configuration tab read-only for FM. Matches the backend boundary (FM_EDITABLE_FIELDS = yield-settings only).

Implementation: FE merged (jy/product). Backend (dashboard.get.stats.ts fund_id param + pending_reserve count) needs pnpm cdk:deploy.

Refs: 09-rbac → Key custody / read-authz, FM Panel PRD §8 + Notification PRD §9 (Notion), v3-26 §15 (funding_shortfall BE request)

v3-74 — Qualified-investor gating (non-US Reg S): investor_tier → investor_status + eligibility_mode, backend-enforced ✅ Decided

v3-74 — Qualified-investor gating (non-US Reg S)

Date: 2026-07-14

Decision: Retire the dormant US-centric investor_tier enum (ACCREDITED/QP) and gate pools by a jurisdiction-based model, enforced backend-side (no on-chain change). Users carry an investor_status (RETAIL/PROFESSIONAL) + qualification_* fields; pools carry an eligibility_mode (STATUS Status Gate / MIN_TICKET Ticket Gate).

Why: Aset is non-US (Reg S) only, so the US ACCREDITED/QP framing was the wrong model and was never read by any gate. Target jurisdictions (SG/HK/EU/JP/UAE/CH/UK/TH/MY) admit non-retail either by professional/qualified status or by a high minimum ticket.

Result (migration 0072): checkKycGating branches on eligibility_mode and returns reason codes (NOT_KYC, KYC_EXPIRED, JURISDICTION_BLOCKED, US_PERSON, NOT_PROFESSIONAL, BELOW_MIN_TICKET); the min-investment floor moved into the gate. New admin PATCH /users/{id}/qualification grants/revokes PROFESSIONAL with an audit trail. Country thresholds are a scaffold config (placeholder values pending BD/legal). Sumsub auto-population is 2nd-phase; v1 is admin-manual.

Refs: 11-db-schema, 03-kyc-identity, 04-pool-models; 17-changelog v3-74; Notion "KYC/적격투자자 — BE 작업".

v3-75 — KYB (institution) onboarding policy: jurisdiction-aligned, UBO ≥20% collect / per-jurisdiction determine 🚫 Superseded by v3-98 (KYB dropped)

v3-75 — KYB institution onboarding policy

Date: 2026-07-14

Decision: Confirm the KYB (legal-entity/institution) onboarding policy from a multi-jurisdiction legal benchmark. KYB is currently scaffold-only / not production-ready (level enum + SumSub KYB level map exist; FE submission is flag-off, UBO capture / admin institution-review unbuilt) and runs as a separate workstream from the individual Reg S gating (v3-74), which ships on the individual track first.

  • Target jurisdictions = same as the KYC/Reg S allowlist (SG·HK·EU·JP·UAE·CH·UK·TH·MY); ID is a separate OJK-sandbox track. No separate KYB country list.
  • Entity eligibility = institutional/regulated financial entities auto-qualify as PROFESSIONAL; other corporates pass a "large-undertaking 2-of-3" test (balance sheet 20M / turnover 40M / own funds 2M — currency per jurisdiction; ADGM own funds $1M, SG S$10M, HK HK$8M/40M, JP ¥500M or QII, TH THB100/200M, MY RM10M).
  • UBO = always resolve to natural persons; collect ≥20% ownership/voting + anyone exercising control (20% = Malaysia's floor, also covers 25% jurisdictions). Determination is per-jurisdiction: standard 25% + control + senior-managing-official fallback, but Japan cascade (>50% sole / else >25% / else control / else representative director) and Malaysia 20%. Threshold is a per-jurisdiction/risk config (high-risk 10–15% supported).
  • Documents = standard set (incorporation cert · constitution/M&A · directors/shareholders registers · registered-address proof · board resolution/PoA · UBO declaration) + jurisdiction extras (SG ACRA Bizfile, HK NAR1, JP registered-matters cert).
  • Representative = a non-director signer needs a board resolution or PoA; identity (liveness) alone is insufficient — link the person to the company record.
  • SumSub absorbs collection/registry lookup/UBO mapping/screening (Enterprise tier — subscription planned August); final accept/reject and legal liability stay with Aset.

Why: Onboarding entities requires resolving who owns/controls them and whether they qualify — none of which Reg S itself supplies (Reg S only requires non-US + offshore transaction). A single 25% UBO rule misidentifies owners in Japan and under-collects in Malaysia, so the model must be config-driven per jurisdiction.

Open (BD/legal sign-off): whether Aset is a "reporting institution" in each jurisdiction (which AML rulebook binds us); final launch countries + exact threshold values; ID DFA-vs-security characterization; SumSub non-US preset scope. Threshold values ship as placeholders.

Refs: 03-kyc-identity → KYB; 17-changelog v3-75; Notion "KYB 관할권 벤치마크 & 결정" + "KYB 활성화" + KYC/KYB 기획서.

v3-76 — Redemption Flow overhaul: reserve-gated instant settle + lockup hard-block ✅ Decided

v3-76 — Redemption Flow overhaul

Date: 2026-07-16

Decision: Two coordinated changes to the instant-redemption path, from the Notion "Redemption Flow" QA.

  1. Reserve-gated instant settlement (no escrow when covered). requestRedemption (instant pools) now branches on reserve at request time:
    • reserveBalance ≥ payout → the LP is burned directly from the investor and USDC is paid out in the same tx (status COMPLETED). No escrow lock, no separate approveRedemption, no notice window.
    • shortfall → the LP is escrowed and the request rests in PENDING_RESERVE; the partner tops up via fundRedemption (no longer auto-completes), then an admin approveRedemption releases the funded payout. FM is alerted (fm_shortfall) at request time. cancelRedemption on a PENDING_RESERVE request refunds any partner funding.
    • Because deposits route 90% to fund_wallet (10% reserve), only redemptions ≤ current reserve settle instantly; larger ones take the fund-funding path.
  2. Lockup is a hard block (model X, aligns with docs 07 3-State Lockup). During LOCKED (now < invested_at + lockup_days — ⚠️ the anchor depends on the pool's implementation now: subscription_end_date + lockup_days from the pool-wide round on, invested_at + lockup_days before it, and both are permanently live (Clones). The hard-block rule this entry decides is unchanged; only what lockup_end is computed from. See 07 → 3-State Lockup Model) redemption is blocked entirely — not merely penalized. requestRedemption reverts RedemptionNotAllowed; the backend (create.ts) and both FE redeem entry points (portfolio inline + RedeemModal) also block. The per-penalty_type fee applies only in the EARLY window (lockup_end ≤ now < maturity). Wind-down waives the lockup. (v3-66 tied NO_EARLY to lockup_days = 0; superseded by v3-83 — lock-up is independent of penalty type, so a NO_EARLY pool may be LOCKED.)

Why: (1) reserve-covered exits should be instant with no lingering escrow (investor UX), while under-funded exits still need the partner + an admin release gate. (2) The contract previously allowed redemption during lockup with a penalty (isLockupActive || isBeforeMaturity), contradicting the docs' 3-State model where LOCKED = cannot redeem. The penalty types exist for the EARLY window, not the lockup window — enforced now on-chain + BE + FE.

FE representation: escrowed LP (in-flight redemption) is shown as "🔒 locked in redemption" in the portfolio (it still counts in position.tokens); the result screen distinguishes instant "Withdrawal Complete" from a pending "funds being prepared for payout".

Refs: 07-redemption 3-State Lockup; 12-triggers Early Redemption Penalty; RedemptionLib.requestRedemption / approveRedemption; Notion "Redemption Flow".

v3-77 — Design system consolidated into one shared package (web + admin SoT) ✅ Decided

v3-77 — Shared design-system package

Date: 2026-07-16

Decision: Make @aset/ds-editorial the single source of truth for the frontend design system across both web and admin — fonts, color palette, type scale, shadcn semantic tokens, the five pattern components, and a living /styleguide showcase. Per-app global.css no longer defines its own palette or fonts.

Why: Tokens were duplicated across apps/web/global.css and apps/admin-web/global.css (admin took its palette from finance-dashboard-kit), and the design references lived in disconnected hand-built copies (a claude.ai artifact, a Drive color HTML) that had already drifted — still showing the pre-swap Fraunces/Inter fonts. One editable source removes the drift.

Result:

  • Values in packages/ds-editorial/src/tokens.css; font loading in fonts.css; components in patterns.tsx; showcase in showcase.tsx.
  • Both apps import fonts.css first, then (admin only) the kit, then tokens.css after the kit so the Aset palette wins — web and admin now render one identical palette.
  • /styleguide is mounted in both apps and renders the live tokens + components; viewing it in both is the parity check.
  • The old claude.ai artifact + Drive color HTML are deprecated — do not use.

Refs: Design System (Frontend); packages/ds-editorial.

v3-78 — Pool status flags: auto-clear exclusivity + wind-down does not freeze ✅ Decided

v3-78 — Status flag auto-clear + wind-down no-freeze

⚠️ Amended by v3-92: "auto-clear in the same BE write" was implemented as a DB-only write, which desyncs from the contract's independent Pausable flag. Auto-clear now also sends on-chain unpause() (after the escalation tx), and is_paused = false is written only if that lands. Date: 2026-07-20

Decision: Keep the two-axis status model (lifecycle_status enum + independent is_paused / is_emergency_frozen flags) — do not collapse into one enum — but make the flags behave as if exclusive:

  • Auto-clear: turning on a higher-severity state clears lower flags in the same BE write (freeze / impairment / wind-down execute all set is_paused = false). Reversing a state does not auto-restore a lower flag (manual re-set). pause is only accepted when the pool is ACTIVE and not frozen.
  • Wind-down no longer freezes (Option A): executeWindDown stops setting is_emergency_frozen = true.

Why: Flags layer on top of lifecycle, so several could read "on" at once and the UI looked ambiguous. Auto-clear guarantees one effective status. The wind-down case was worse than cosmetic: is_emergency_frozen outranks WIND_DOWN in priority, so a wound-down pool displayed "Frozen (all halted)" and hid the pro-rata redemption path — the opposite of wind-down's purpose. On-chain, requestRedemption has no whenNotFrozen guard and RedemptionLib allows redemption during wind-down, so redemption always worked; only the DB flag + FE display were wrong. Not merged into one enum because the contract keeps whenActive / whenNotPaused / whenNotFrozen as independent modifiers, and IMPAIRED+writedown is an orthogonal combination a single enum can't express.

Result:

  • BE (apps/infra/lambda): pools.post.wind-down drops is_emergency_frozen, adds is_paused: false; pools.post.freeze clears is_paused on freeze; pools.post.impairment clears is_paused on execute; pools.post.pause guards ACTIVE + not-frozen. tsc -b green.
  • Investor web already renders each status correctly (the earlier paused/upcoming empty-sidebar bug was fixed separately); once BE stops freezing, wind-down shows "Winding down" with the redemption path.
  • Admin pool-controls redesign (single current-status indicator, CTAs ordered safe→irreversible, higher state disables lower CTAs) — handoff to admin-web session.

Refs: 10-status-machines → Auto-clear; CLAUDE.md Pool 복합 상태; 17-changelog v3-78; Claude-Plan A1 spec.

v3-79 — Early-exit penalty → PRINCIPAL_BASED (YIELD_BASED deprecated) + partial-redemption policy ✅ Decided

v3-79 — Principal-based penalty + partial policy

⚠️ Partially superseded by v3-84: the YIELD_BASED deprecation below is reversed — YIELD_BASED is reinstated for lock-up pools (lock-up gated to release only after the first yield distribution). PRINCIPAL_BASED-as-default and the partial-vs-full policy still stand. Date: 2026-07-20

Decision:

  • Make PRINCIPAL_BASED the default early-exit penalty (% of the redeemed principal) and deprecate YIELD_BASED (no longer selectable at creation; existing pools migrated to PRINCIPAL_BASED).
  • Partial redemption is allowed by default in ACTIVE / IMPAIRED, penalty pro-rated on the redeemed slice only. MATURED / WIND_DOWN are full-only.

Why: A "% of accrued yield" penalty is 0 whenever yield hasn't accrued yet or was already claimed — it fails to deter exit in exactly the EARLY window it's for. Benchmark research found no real precedent for yield-% penalties: bank CDs forfeit contractual interest (and can bite principal), tokenized RWA funds (Ondo, Superstate, Franklin) charge no early-exit penalty at all, and where a fee exists it's a flat % of principal (Goldfinch 0.5%, bond funds ≤2%). Partial-with-pro-rata is the industry norm (Maple, Centrifuge, Clearpool); full-only only makes sense in terminal states where the position is being closed.

Result — no contract change, no column deletion:

  • The contract already implements PRINCIPAL_BASED as grossAmount × penalty_rate_bps / 10000 (RedemptionLib), and grossAmount is the redeemed slice → partial pro-rata is automatic and identical for instant and epoch. YIELD_BASED stays as a dead on-chain branch for old clones.
  • penalty_type / penalty_rate_bps / penalty_fee_amount columns all kept — YIELD_BASED is an enum value, not a column.
  • Pending (handoff): FE create form removes the YIELD_BASED option + fixes the copy; BE data-migrates existing YIELD_BASED pools (⚠️ rate must be re-set, not carried — bps meant "% of yield" and now means "% of principal"); BE rejects partial in MATURED/WIND_DOWN (FE locks amount to full). On-chain FullRedemptionRequired guard deferred to the next redeploy batch (not a safety issue).

Refs: 07-redemption → Penalty Types / Partial vs Full; CLAUDE.md Penalty Types; 17-changelog v3-79; Claude-Plan B spec + B-BE-handoff.

v3-80 — Rule #7 revision: admin create/edit inputs accept percent (one-way lossless → bps) ✅ Decided

v3-80 — Admin percent input for bps ratio config

Date: 2026-07-20

Decision: Revise rule #7 (and the v3-73 carve-out that had kept admin input in bps). The admin create/edit form now accepts ratio config (reserve_bps, redemption_gating_bps, penalty_rate_bps) as percent — operators type e.g. 2.5, and the form converts to bps (Math.round(pct × 100)) at save. Storage, API payloads, Lambda, and the contract stay bps (unchanged).

Why: Typing raw bps ("250" for 2.5%) is error-prone and inconsistent with how the value is displayed everywhere else (percent since v3-73). The v3-73 fear — a bidirectional percent↔bps bridge getting the conversion wrong — is avoided because this is a single one-way conversion at the input boundary (% typed → bps stored), and the round-trip is lossless (percent capped at 2 decimals ↔ integer bps, 1 bps = 0.01%).

Result / scope:

  • Allowed: admin input one-way % conversion (v3-80) + the existing display-only formatter (v3-73). Still forbidden: storing percent, or any bidirectional bridge at the storage/transport/contract layer.
  • Validation: admin input validates isPercent (0–100, 2 dp) then converts; all other paths keep isBps integer validation.
  • Pending (handoff): admin-web create/edit form fields → suffix="%" + = X bps hint; payload.ts sends converted bps (structure unchanged).

Refs: CLAUDE.md rule #7; 14-decisions v3-73; 17-changelog v3-80; Claude-Plan B spec (B3).

v3-81 — Create validation + REVOLVING / APY display: maturity > lockup, REVOLVING publish exemption, per-year APY ✅ Decided

v3-81 — Maturity/lockup guard + REVOLVING & APY display

Date: 2026-07-20

Decision:

  • Maturity must be strictly greater than lockup (maturity_days > lockup_days) — equal values are rejected (was ). Boundaries unchanged: lockup_days = 0 passes, maturity_days = 0 (REVOLVING) is exempt.
  • REVOLVING pools (no maturity) are exempt from the publish end_date requirement — publish requires start_date always and end_date/maturity only for fixed-term pools.
  • Investor FE shows APY as a per-year rate for open-ended pools. APY is annual: fixed-term pools pro-rate it across the remaining term ("Est. value at maturity"); REVOLVING pools, having no maturity, now show the annualized yield (~$X / year) instead of a full-year lump mislabeled "at maturity".

Why: Equal maturity/lockup leaves a zero-length EARLY window (nonsensical). REVOLVING has no end date, so requiring one blocked publishing valid perpetual pools. The "$108 at maturity" figure over-promised a fixed total return on a variable annual rate for pools that never mature.

Result:

  • Investor web (invest-sidebar.tsx): per-year display for no-maturity pools implemented; tsc -b green.
  • Pending (handoff): FE create validation →reject-equal (3 sites); BE pools.post.create / patch.update maturity>lockup guard; BE publish gate requires end_date except REVOLVING.

Refs: 07-redemption → 3-State Lockup; 17-changelog v3-81; Claude-Plan B spec (B4/B5) + B-BE-handoff.

v3-82 — Partner funding auto-settles a PENDING_RESERVE redemption (removes admin approve gate) ✅ Decided

v3-82 — Auto-settle on partner funding

Date: 2026-07-20

Decision: In the instant-redemption shortfall path, fundRedemption auto-settles the request the instant the pool balance covers the payout — burn LP + pay the investor + COMPLETED, in the same call — with no admin approveRedemption step. This reverses the v3-76 sub-decision that gated the funded payout on an admin release.

  • Reserve check stays at request time (v3-76): reserveBalance ≥ payout → immediate settle at request; shortfall → escrow LP + PENDING_RESERVE + fm_shortfall alert.
  • Partner tops up → auto-settle. fundRedemption (partner, YIELD_DEPOSITOR_ROLE) checks the total contract balance after each top-up; once it covers the payout it calls the shared payout path immediately (a still-short top-up leaves the request PENDING_RESERVE for a follow-up fund).
  • No compliance/anomaly hold on the instant path. Full auto — the admin is out of the loop (decision: no hold).
  • approveRedemption retained as an optional manual settle only for the rare case where the reserve grows independently (e.g. new deposits) so the balance covers a still-PENDING_RESERVE request before the permissionless claimRedemptionFallback notice elapses. Not part of the normal flow.

Why: The v3-76 admin release gate added a manual step with no value once the partner has funded the payout on-chain — the money is already in the pool, so settlement should be automatic. The backend (fund.ts + fundRedemptionOnChain) was already written for auto-completion; v3-76 changed only the contract, leaving BE ↔ contract inconsistent. This restores the auto-complete the backend expected and matches the product intent ("FM이 리저브를 채우면 밀려있던 요청이 자동 처리").

Result:

  • Contract RedemptionLib.fundRedemption (instant) auto-settles on coverage; approveRedemption comment reworded to "optional manual settle". Foundry: 307 tests pass (added test_FundRedemptionAutoCompletes, test_PartialFundStaysPendingReserve; test_ApproveRedemptionByOracle repurposed to the manual path). PlatformPool size 19,480 / 24,576 (unchanged headroom).
  • Backend already handled it (fund.ts completes via complete_redemption_atomic when onChainResult.completed); no logic change needed.
  • [User] contract redeploy required — new pools pick up the behavior; already-deployed pools keep old bytecode.

Refs: 07-redemption → Instant Redemption Flow; v3-76 (reversed sub-decision); RedemptionLib.fundRedemption.

v3-83 — Lock-up is independent of penalty type (removes the NO_EARLY⇔lockup=0 coupling) ✅ Decided

v3-83 — Lock-up ⫫ penalty type

Date: 2026-07-20

Decision: lockup_days and penalty_type are independent config axes (supersedes v3-66):

  • Lock-up (lockup_days) defines the LOCKED window, during which redemption is blocked entirely — regardless of penalty type.
  • Early-exit penalty (penalty_type + rate) is the fee charged only in the EARLY window (after lock-up ends, before maturity). NO_EARLY means that fee is 0 — it does not mean "no lock-up" or "exit anytime".
  • So NO_EARLY + lockup_days > 0 (locked, then penalty-free exit) is a valid config, as is NO_EARLY with any redemption_type.

Why: The two are genuinely distinct in RWA terms — a "30-day lock-up, then penalty-free redemption" product is normal. v3-66 conflated them by defining NO_EARLY as "free exit from t0", which forced lockup_days = 0 and ON_DEMAND. In the create wizard this produced a dead-end: Step 3 demanded a penalty "in Step 5", but Step 5 hid the penalty selector when no maturity was set — the requirement was unsatisfiable. The redemption runtime already treats lock-up and penalty independently (RedemptionLib: LOCKED blocks regardless of penalty; NO_EARLY → penalty 0); only the config-time guards encoded the coupling.

Result:

  • Contract: removed the GovernanceLib.setLockupDays NoEarlyRequiresZeroLockup guard and the PoolConfigLib NO_EARLY ⇒ ON_DEMAND guard; dropped the now-unused NoEarlyRequiresZeroLockup error (PlatformPool + GovernanceLib). Foundry: 307 tests pass (3 revert-tests converted to success + a new lock-up+NO_EARLY redemption assertion). ABI regenerated (error removed, no selector change).
  • Backend: pools.post.create / pools.patch.update no longer reject NO_EARLY + lock-up or NO_EARLY + non-ON_DEMAND.
  • FE: create wizard drops the Step-3 lock-up↔penalty gate and the NO_EARLYON_DEMAND forcing; investor web effectiveLockupDays returns the real lockup_days for every penalty type, and the portfolio redeem gate keys purely on LOCKED (a NO_EARLY pool can now be LOCKED). tsc -b green across web + admin-web.
  • [User] contract redeploy required — the removed guards only affect newly deployed pools (clone-based, and the v3-66 guard was still pending deploy).

Refs: 07-redemption → 3-State Lockup; 04-pool-models → penalty_type; 24-field-governance; 17-changelog v3-83; supersedes v3-66; CLAUDE.md Penalty Types.

v3-84 — Reinstate YIELD_BASED penalty (lock-up pools, release gated to first yield distribution) — reverses the v3-79 deprecation ✅ Decided

v3-84 — YIELD_BASED reinstated with lock-up gating

Date: 2026-07-20

Decision: YIELD_BASED is reinstated as a valid early-exit penalty type, reversing the deprecation half of v3-79. PRINCIPAL_BASED stays the default, and the v3-79 partial-vs-full-redemption policy is unchanged. YIELD_BASED gets two guardrails:

  • Lock-up pools only — selectable only when lockup_days > 0. A no-lock-up pool cannot use it.
  • Lock-up release is gated to the pool's first yield distribution — the LOCKED window may not end before the first distributeYield. An investor therefore never enters the EARLY window with zero accrued yield.

Mechanics: penalty = accrued_yield × penalty_rate_bps / 10000 — a % of the yield the investor has accrued — deducted from the redeemed principal: payout = redeemed_principal − penalty, penalty → Pool reserve (like the other types). Already-distributed yield stays with the investor; you can't claw back yield already paid out, so the clawback reduces principal instead.

Why (reverses the v3-79 rationale): v3-79 deprecated YIELD_BASED because a "% of accrued yield" penalty is 0 when no yield has accrued yet, making it toothless in exactly the EARLY window it should guard. The two guardrails remove that failure mode by construction: YIELD_BASED only exists on lock-up pools, and lock-up cannot release before the first yield distribution — so by the time EARLY begins, yield has necessarily accrued and the penalty bites. It is a coherent "claw back a share of earned yield if you leave early" deterrent for lock-up products. It also un-orphans a real contracted pool: Joob (SSA §4.2 = "50% of dividends forfeited") maps exactly to YIELD_BASED 5000 bps (see 20-joob-pool-config) — the v3-79 deprecation contradicted it.

Deduction target — decided 2026-07-21: from principal. The penalty comes off the redeemed principal (payout = redeemed_principal − penalty), not the yield — because already-distributed yield cannot be clawed back after it has been paid out, so the deterrent has to reduce what's returned instead. This required a contract change + redeploy, since shipped: the deployed RedemptionLib computes payout = redeemed_principal − penalty for every penalty type and routes the penalty to fund_wallet (v3-85 superseded the original reserveBalance destination). isYieldPenalty is kept in the request struct for ABI stability but is always false.

Result / pending (handoff):

  • Contract:shipped — the deployed RedemptionLib computes payout = redeemed_principal − penalty for YIELD_BASED with the penalty routed to fund_wallet (v3-85). The rate is applied via RedemptionConfig (v3-18 fix — no longer forfeits the full yield); isYieldPenalty stays in the struct for ABI stability but is always false. The lock-up-release-after-first-yield gate is a new invariant — enforce off-chain (BE) first; on-chain guard is a follow-up if needed.
  • Backend: re-allow YIELD_BASED in pools.post.create / pools.patch.update only with lockup_days > 0; enforce lock-up-release ≥ first yield distribution; penalty_rate_bps means "% of accrued yield" for this type (contrast PRINCIPAL_BASED = "% of principal" — ⚠️ do not carry a rate across a type change without re-confirming the basis).
  • FE: re-add the YIELD_BASED create option, shown only once a lock-up is set; copy explains "% of earned yield clawed back on early exit".

Refs: 07-redemption → Penalty Types; 20-joob-pool-config; 17-changelog v3-84; reverses the deprecation in v3-79; CLAUDE.md Penalty Types. Source: JY (product), 2026-07-20 Redemption Flow QA.

⚠️ Penalty destination superseded by v3-85 (2026-07-21): the "→ Pool reserve" above is reversed — the penalty now goes to fund_wallet, not reserveBalance. The payout = principal − penalty deduction rule here still stands.

v3-85 — Early-exit penalty destination: fund_wallet, not Pool reserve (all penalty types) ✅ Decided

v3-85 — Penalty → fund_wallet (reverses the v3-84 reserve destination)

Date: 2026-07-21

Decision: Every early-exit penalty (PRINCIPAL_BASED, FLAT_FEE, YIELD_BASED; instant and epoch paths) is transferred to the pool's fund_wallet — the fund manager's capital wallet — instead of being credited to reserveBalance. This reverses the "penalty → Pool reserve" destination in v3-84 and the prior implicit reserve destination for PRINCIPAL_BASED / FLAT_FEE. The penalty amount / formula is unchanged — only where it lands.

Why: Product decision (JY, Redemption Flow QA) that the early-exit penalty belongs to the fund manager, not the pool. ⚠️ Economic implication (deliberate — note the real mechanism): steady-state navPerToken is oracle-set and independent of reserveBalance (reserve only feeds NAV in WIND_DOWN, where nav = reserveBalance / totalSupply), so crediting the penalty to reserve did not raise remaining LPs' day-to-day NAV. What it did do: (1) keep the penalty in the pool's redemption liquidity buffer (so fewer PENDING_RESERVE shortfalls), and (2) raise the wind-down pro-rata recovery for remaining LPs. Routing it to fund_wallet removes both of those and hands the penalty to the FM. Destination confirmed (JY, 2026-07-21) — the fund_wallet decision is final and implementation can proceed; the economic rationale / investor-facing framing is being documented with Hana, which does not reopen the destination.

Result / pending:

  • Contract (RedemptionLib.sol): replace s.reserveBalance += penalty with a safeTransfer of the penalty (denormalized to the request stablecoin) to s.fundWallet at all penalty-credit sites — the instant sufficient-reserve path, _executeRedemptionPayout, and _applyEpochPenalty (PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED) — and draw the penalty down from reserveBalance / totalDeposited so the pool's accounting matches the USDC that physically leaves. Combine with the v3-84 YIELD_BASED payout change (payout = principal − penalty). ⚠️ Redeploy required (new pools); the deployed contract keeps the reserve behavior until CH redeploys.
  • Backend / FE: penalty math is on-chain SoT (v3-18) — no off-chain recompute. Display copy that says "penalty → reserve / benefits the pool" is updated to "→ fund manager".

Refs: 07-redemption → Penalty Types; 17-changelog v3-85; supersedes the penalty destination in v3-84; CLAUDE.md Penalty Types; B-BE-handoff §1. Source: JY (product), 2026-07-21 Redemption Flow QA.

v3-86 — Audit Log re-scope: audit-first SoT + snapshot actor + expanded write coverage + Audit/Activity split 🔧 Decided · 🛠 Built · deep-link + failure paths open

v3-86 — Audit Log re-plan (pins identity on the loose v3-41 framing)

Date: 2026-07-21

Context: The audit log was assembled piecemeal on top of v3-41 (two-stream feed) without ever deciding what the log is. Result: actor_id was stored but never resolved, so the UI showed raw UUIDs / "Unknown" / "SYSTEM" (weekly 2026-07-20: "Actor ID → user name + role"), and coverage / immutability were ad-hoc.

Decision (6 layers):

  • Identity: the audit trail is the audit record (SoT); the operational Activity feed is a read-view layered on top, not a co-equal stream.
  • Write scope: WRITE only human discretionary acts touching money / permissions / investor state / pool-fund state / sensitive data. Economic + on-chain events stay read-view UNION only (no double-write — v3-41 preserved). Adds the previously-unwritten gaps: pool/tranche create·deploy·publish·update·delete; fund CRUD + fund-member; admin-user CRUD + permission + admin-wallet bind/verify; investor qualification/tier.
  • Actor: hybrid — the human write-stream snapshots actor_name + actor_role at write time (join is fallback only); investor economic events resolve via users join; system/scheduler = "System". Display = name · role-badge; on-chain signer = System + tx_hash.
  • Schema: standard record = When · Who(snapshot) · What · Target(label snapshot) · Change (before→after, structured) · Why(reason) · Proof(tx_hash) · Severity · Outcome. reason required on high-risk acts only (impair · wind-down · freeze · permission · qualification). Failed attempts logged with outcome=failure.
  • IA: Audit / Activity two tabs (Audit = human write-stream, default; Activity = economic + on-chain full feed). Filters: date · actor search · category · outcome · severity. Entity-scoped deep-link. FM sees own-fund Activity only (no internal audit / permission / PII).
  • Retention / immutability: DB-level append-only (revoke UPDATE/DELETE; only the archive path may remove a row). 5-year retention, then cold-archive out of the hot table and drop from the feed (replaces the current hard-purge) — records are relocated, never destroyed. Hash-chain tamper-evidence deferred.

Result / pending:

  • DB (0080–0082, done): added actor_name/actor_role/actor_type/entity_label/before_state/after_state/reason/outcome to activity_events; a BEFORE UPDATE/DELETE trigger makes the table append-only for every role incl. the Lambda service key; audit_feed re-created with actor resolution + actor_type. The archive scheduler now calls archive_expired_activity_events(), which moves aged rows into the new activity_events_archive cold table (atomic INSERT + DELETE) instead of hard-purging — S3 cold storage is a later enhancement, not what shipped. See 11-db-schema and 13-operations.
  • BE (done): recordActivity() takes snapshot actor / entity_label / before-after / reason / outcome (lib/shared/audit/activity.ts). Writers added for all 4 gap categories — pool create·update·delete·deploy·publish (pools.post.create, .patch.update, .delete, .worker.deploy, .post.lifecycle), fund CRUD + fund-member (funds.*, fund-members.*), admin-user CRUD + permissions + wallet bind (admin-users.*, admin-wallet.post.verify), investor qualification (users.patch.qualification) — plus NAV_APPROVE/NAV_OVERRIDE from the A4 path. reason is handler-enforced with a 400 on the high-risk acts (impairment propose, emergency freeze, qualification change).
  • FE (admin-web/audit-log.tsx, done): Audit / Activity tabs with FM locked to their fund's Activity; actor_name + role badge / System; date · search · category · outcome · severity filters with server-computed counts; before→after + failure detail in the expanded row; mandatory reason input on high-risk confirm modals (pool-controls.tsx requireReason).
  • Open: entity deep-link — the target still renders as plain entity_label · type/id text, not a link. Failure-path coverage is thin — only pools.worker.deploy writes outcome: 'failure' today. MY-jurisdiction retention 6–7y (legal); no back-fill of pre-snapshot actor_id (join fallback only); hash-chain only if a regulator requires it; S3 cold storage behind the activity_events_archive table.

Refs: 13-operations → Audit Log Retention & Export; 17-changelog v3-86; extends v3-41; 11-db-schema; Notion "Admin-web Audit Log". Source: JY (product), 2026-07-20 weekly + 2026-07-21.

v3-89 — Redemption terminology: OPEN_ENDED is canonical ("revolving" = UX nickname); open-ended pools have no early-exit penalty ✅ Decided

v3-89 — OPEN_ENDED canonicalization + no-penalty-for-open-ended

Date: 2026-07-23

Context: The 3-state lockup model used "REVOLVING" as if it were a maturity_model value, but the only enum values are FIXED_TERM / OPEN_ENDED ("REVOLVING" is display/UX copy the API rejects). The table also contradicted itself for open-ended pools — the EARLY row said "or no maturity for REVOLVING" (penalty applies) while the FREE row + edge notes said FREE begins right after lock-up (no penalty).

Decision:

  • Model — an OPEN_ENDED (no-maturity) pool has no EARLY window: LOCKED → FREE straight after lock-up, so an early-exit penalty never applies. EARLY (and therefore any penalty) exists only for FIXED_TERM pools with a maturity. Matches the contract (maturityDate = 0now < maturityDate is false ⇒ not EARLY) and the create/edit wizard (penalty section hidden without a maturity, v3-88 D2). The EARLY row's "or no maturity for REVOLVING" was the bug and is removed.
  • TerminologyOPEN_ENDED is canonical/normative (DB enum, handlers, contract). "revolving" is a product/UX nickname (underlying assets cycle, e.g. 3-6 month loans) kept only in its one definitional spot — never as an operative value in rules/tables. Normalized 07-redemption (3-state table + epoch section) and 04-pool-models (epoch axis); renamed the heading "Epoch-Based Redemption (REVOLVING pools)" → "(Open-Ended Pools)" and repointed its 10 cross-links.

Result: docs-only. 07-redemption, 04-pool-models (+ anchor updated across 6 files). The LINE BK example (OPEN_ENDED + PRINCIPAL_BASED) is flagged inert — LINE BK to switch to FIXED_TERM if a penalty is actually intended. Historical decision/changelog entries keep their original "REVOLVING" wording; only their section links were repointed.

Refs: 07-redemption → Epoch-Based Redemption, 04-pool-models → maturity_model; 17-changelog v3-89; clarifies v3-46. Source: JY (product), 2026-07-23.

v3-90 — Joob fund-data semantics: NPL at DPD 90 / write-off ~180 DPD, cash-received fund value, EOD 09:00 refresh ✅ Decided

v3-90 — Joob loss recognition + fund-value basis (call 2026-07-24)

Date: 2026-07-24

Context: Two long-open Joob data-request items — write-off timing and whether current_fund_value is accrual- or cash-based — were resolved on the 2026-07-24 Joob call.

Decision (confirmed by Joob):

  • NPL at DPD 90; write-off at ~6 months (≈180 DPD). A loan is classified non-performing at 90 DPD but stays on the book — it remains in the DPD-90 bucket and counts toward outstanding principal, feeding npl_ratio. Actual write-off (removal → realized cumulative_loss; Joob's separate "written-off / in recovery" line) happens at roughly 6 months (~180 DPD). So npl_threshold_days = 90 and write_off_policy ≈ 180 do not collapse: there is a real 90–180 DPD on-book NPL window, and the risk badge's leading NPL signal fires normally (e.g. the 2026-07-24 snapshot ≈ 10.6% NPL → HIGH). Whether Joob provisions partial loss on OJK graduated tiers during 90–180 is unconfirmed; moot while the automated DPD→NAV pipeline stays deferred (NAV manual).
  • current_fund_value = cash-received basis. Fund value counts only interest actually received, not approved-but-uncollected (accrued / 미수) interest; accrued interest is excluded until it settles to cash. Current approved→received lag ≈ 1–2 days.
  • Cadence → EOD daily 09:00. Joob is moving fund value (like DPD) to an end-of-day pipeline refreshed every morning at 09:00, so the ingest lands once per day.
  • Aset mirrors the cash-received fund_value; the accrued_income request is dropped. Because the approved→received lag is only ~1–2 days — one day of interest ≈ 0.04% at 15% APY, below NAV display precision — Aset ingests Joob's cash fund_value as reported rather than reconstructing NAV as principal + cash + accrued − loss. The accrued_income API ask (Joob data-request §2) is dropped; its snapshot column (migration 0068) stays dormant — revisit only if a material (e.g. quarterly) accrual mismatch appears. Investor-facing accrued yield is unaffected (computed from yield_distributions, not this fund-level figure).

Result: docs-only (semantics/config, no schema or contract change). Pins Joob's npl_threshold_days = 90 + write_off_policy ≈ 180 DPD (~6 months) — NPL and write-off are separate, not collapsed — and settles the fund-value basis as mirror-received (accrued dropped). The risk badge's leading NPL signal fires normally for this pool (no code change needed). The deferred automated DPD→NAV writedown (v3-13) is unaffected — still blocked on per-loan data; NAV stays manual. Resolves the Joob data-request open items on write-off timing and fund-value accrual basis.

Refs: 20-joob-pool-config → Fund value basis, 06-writedown-nav → NPL vs Cumulative Loss; refines v3-72 and the deferred automated DPD writedown (v3-13). 17-changelog v3-90. Source: Joob call, 2026-07-24.

v3-91 — Epoch redemption engine redesign: calendar-anchor schedule + demand freeze + settlement escrow (double-commit fix) + yield-stop-at-request ✅ Decided · 🛠 Shipped (settlement escrow unconfirmed)

v3-91 — Epoch redemption engine redesign (audit + design session 2026-07-24)

Date: 2026-07-24 · Status: decided, not yet implemented — the current on-chain epoch model (see 07-redemption → Epoch-Based Redemption) is unchanged; this is the target for the contract redesign handed to CH.

⚠️ Amended by v3-93: the freeze below is achieved by a hard request-window gate (requestRedemption reverts outside the window), not by the accepting-cursor split alone — a lone cursor split never rejects a request, which silently reverts this decision to Model A. v3-93 also fixes the schedule reference point (the admin-entered funding date per cycle, not a month-end rule and not the cutoff), pins the semi-auto knob so it is free only until the request window opens, and scopes cancellation to the request window.

Context: A line-by-line audit of RedemptionLib / YieldLib against the epoch product intent surfaced four coupled gaps that only close together, plus several confirmed policy decisions. Record of source: Notion "Epoch Redemption — 기획 방향 & 코드 검수 정리" (CH Handoff).

Decision — one engine redesign (🔴A scheduling + 🔴B double-commit + 🔴G fund isolation + freeze), plus policy:

  • Calendar-anchor schedule (replaces lazy-start + back-to-back). Boundaries derive deterministically from a fundingAnchor fixed at pool creation, not from the first redemption request. New config axes: scheduleType (monthly / quarterly), fundingAnchor, requestWindowDays, recallLeadDays. ⚠️ Corrected — this entry had it backwards: the sentence here originally read "calendar-based; the week-multiple 28/84-day approximation is dropped". The opposite was decided (2026-07-27) and shipped — 28/84-day week multiples are what the fail-open default advances by, precisely so the contract never does calendar arithmetic, which is also what v3-93 §2 states. A real month-end rule was rejected as expensive on-chain and pointless given the admin enters the actual date each cycle. In the contract there is no scheduleType field at all: epochDurationDays carries the period, and the MONTHLY → 28 / QUARTERLY → 84 mapping is BE-side. Reverse-derived: 신청마감 = fundingAnchor − recallLeadDays, 신청시작 = 신청마감 − requestWindowDays, claim = fundingAnchor (funding day). processingDays = 0 (on-chain settlement is same-day; the only human review, NAV approve, is pinned to before funding — the monthly NAV cycle). Presets: monthly 7 / 10, quarterly 14 / 21 (prefilled defaults, admin-editable per pool).
  • Demand freeze at cutoff (A1+A2). Split the settlement cursor (currentEpochId) from the accepting cursor (acceptingEpochId = floor((now − anchor) / duration) + 1). Requests after cutoff enroll into the next epoch — this freezes the closing epoch's demand so the confirmed redemption total can be sent to the publisher (FM) to trigger recall. fundRedemption top-ups target the settling (frozen) epoch, not the accepting cursor (else money lands on the wrong epoch and recallLeadDays breaks).
  • recallLeadDays gate. executeEpoch is allowed only after cutoff + recallLeadDays (not right after the request window ends), giving the publisher the recall/funding window. Anomalies handled by the existing auto-gates (circuit breaker / NAV staleness / deviation cap) + claim hold.
  • Settlement reservation — double-commit fix (🔴B) + fund isolation (🔴G). At settlement executeEpoch immediately debits the filled gross from the liquidity buckets (epochFundTopUp[id]heldFundReleasesreserveBalance) and reserves it; claimRedemption draws only from that reservation, not live reserveBalance/heldFundReleases. Removes the bug where consecutive epochs count the same R/H (settlement only promised; the debit happened at claim), and stops yield / reserve / redemption backing from cross-draining the shared contract balance. Also fixes the epochFundTopUp phantom (counted in fill, never debited). ⚠️ Superseded in form by v3-100: this entry originally specified a per-epoch epochClaimable[id] pot. That was withdrawn on 2026-07-27 — a claim spans epochs (H[latest] − H[vintage]), so draining per-epoch pots is O(V) and breaks the O(1) guarantee. The shipped mechanism is a single redemptionCommitted scalar, with invariant physicalBalance ≥ reserveBalance + heldFundReleases + redemptionCommitted + unclaimedYield.
  • Yield stops at request (C3 + I — one set). ⚠️ SUPERSEDED by v3-131 (3), shipped 2026-08-20 — do not implement this. It reversed the earlier "method (b)" that accrued on locked LP until claim, by removing the lockedYield credit and excluding escrowed LP from the denominator (totalSupply() − balanceOf(self)), sending escrow-period yield entirely to the remaining holders. The half that survives is the diagnosis: crediting until claim pays an investor for delaying their own claim. The half that did not is the remedy — stopping at request is a redistribution, not a saving, and in a pool where every holder must request at maturity it drove the denominator to zero and owed the coupon to nobody. The stop now sits at settlement, between the two.
  • Carry-first FIFO (C8). Next epoch fills rolled-over (unfilled) demand before new demand, via a 2-tier ladder (carry / new buckets, each pro-rata internally) that keeps claim/settlement O(1).
  • NAV propose/approve/override + sanity floor (A3 + C9/H). BE computes/proposes NAV; the approver accepts or overrides (override → audit log: who / when / proposed→applied); both pass the deviation cap + a bug-guard floor NAV > 0 (no meaningful floor like 0.5 — real losses pass through per Rule 2).

Result: contract redesign (CH). 🔴A + 🔴B + 🔴G + freeze must ship as one PR / one audit unit (shared state); C3+I (yield) and C8 (FIFO) layer on top after the engine lands. Non-contract follow-ups (JY, tracked in the Notion PRD pages): FM redemption-amount notification, investor epoch-date display + timeline bar, create/edit schedule UI + semi-auto funding-date operation, NAV approve/override UI, INVESTOR_CANCELLED reporting split. (LIQUIDITY_WINDOWS is no longer a follow-up — migration 0106 removed the value outright and pools.post.create carries no reference to it.)

Refs: 07-redemption → Epoch-Based Redemption; revises the epoch behaviour of v3-26 and the yield method of v3-18; interacts with v3-46/v3-38. 17-changelog v3-91. Source: audit + design session (JY), Notion "Epoch Redemption" handoff, 2026-07-24.

v3-88 — Admin redemption-step wizard: consistency + config guards (D1–D9) ✅ Decided

v3-88 — Admin pool create/edit redemption-step hardening

Date: 2026-07-23

Context: The admin pool create/edit Redemption & penalty step let a user express configs that are meaningless or that permanently block redemption, and used field labels that misread. Audited against the on-chain RedemptionLib, the backend eligibility path, and the specs (Notion "Epoch config 정합성").

Decision (admin-web wizard only — no DB/schema/contract/BE change):

  • Epoch-only fields gated by epoch (D1/D3): redemption_gating_bps and the instant-only "Expected settlement" notice show only in their applicable mode; stale values are cleared on mode switch.
  • Penalty section gated by maturity (D2): edit now matches create — hidden when there is no maturity (no EARLY window ⇒ nothing to penalize).
  • LIQUIDITY_WINDOWS removed from the dropdown (D5): it is unwired (deploy sends an empty window array ⇒ every request reverts NotInLiquidityWindow ⇒ redemption blocked forever). The enum stays on-chain; edit keeps a read-only legacy fallback for any pool already deployed with it.
  • OPEN_ENDED + FIXED_MATURITY blocked (D6): an open-ended pool never reaches a maturity, so FIXED_MATURITY would block exits forever; the wizard coerces redemption_type → ON_DEMAND and disables it, with a validation guard.
  • FIXED_MATURITY suppresses the epoch selector (D7): a matured pool pays out once, so a settlement cadence is N/A — the wizard hides the settlement block and forces epoch_duration_days = 0 for redemption_type = FIXED_MATURITY. This is an admin-UX guard, not an on-chain rule (the contract still permits the combo; it is simply never a useful base config — staggered maturity payouts are a wind-down / lifecycle concern, not base epoch). It does not change v3-46: epoch stays orthogonal to maturity_model, and FIXED_TERM + ON_DEMAND + epoch (early-exit run management) remains valid.
  • Settlement UX (D8): the settlement field is now an explicit Instant / Epoch mode select that reveals a cycle-length input only for epoch, instead of overloading a "0 vs N days" number.
  • redemption_type relabeled (D9) to what it controls — early exit or not: FIXED_MATURITY → "Redeem at maturity only", ON_DEMAND → "Redeem on request"; help drops the misleading "exit any time" and notes lock-up + early-exit penalty.

Result: admin-web pool-create / pool-edit step-redemption.tsx + pool-create payload.ts / validation.ts. tsc -b green. No deployed pool uses FIXED_MATURITY (Joob is FIXED_TERM + ON_DEMAND), so the new guards conflict with nothing live.

⚠️ Follow-up (CH, contract/BE — outside the wizard): FIXED_MATURITY with no maturity set diverges — on-chain _validateRedemption allows it (block.timestamp < 0 is false ⇒ always allowed), but backend redemption-eligibility.ts blocks it. Reconcile in the contract/BE track.

Refs: 07-redemption → Epoch-Based Redemption, 04-pool-models → epoch_duration_days; 17-changelog v3-88; tightens v3-46 at the admin layer; Notion "Epoch config 정합성". Source: JY (product), 2026-07-23.

v3-87 — Pool detail IA: Controls tab is actions-only; NAV trend → Overview chart; governance/lifecycle history → global Audit Log ✅ Decided

v3-87 — Admin pool-detail Controls/Overview split

Date: 2026-07-22

Context: The admin pool-detail Controls tab had accreted three different things at once — live actions (pause/freeze/impair/wind-down/governance), an inline NAV-change table, and a governance/lifecycle history feed — with an inconsistent visual hierarchy (some sections had a group label but no title, others a title but no group label).

Decision:

  • Controls = actions only. Every section reads at one consistent 3-tier hierarchy: group eyebrow (category) → panel title → one-line subtitle. Groups: Deposits, Danger zone, Pool lifecycle (danger/lifecycle red, others neutral — colour signals meaning only). Each control is a compact row (title + meaning + action); long body paragraphs were dropped because the confirm dialogs already carry them.
  • Governance change and NAV change are actions in Controls, each opening a weightier dialog (timelock notice + current-values context + primary propose + pending-proposal management), not permanently-expanded inline forms.
  • NAV trend → Overview. The read-only NAV-per-token history now renders as a brand-coloured time-series chart with period tabs (1M/3M/6M/1Y/All) in Overview; proposing/overriding NAV stays in Controls.
  • Governance & Lifecycle history removed from pool-detail — the global Audit Log (v3-86) is the single home for it.

Result: admin-web pool-detail refactor (no BE/DB/schema change): compact ControlRow + shared GroupEyebrow/PanelTitle primitives; NavChangeDialog + NavChart (adds recharts, already used by web); deleted the nav-changes-panel / governance-history-panel widgets.

Refs: 17-changelog v3-87; builds on v3-86 (Audit Log). Source: JY (product), 2026-07-22.

v3-92 — Status-flag auto-clear must also unpause on-chain (amends v3-78) ✅ Decided

v3-92 — On-chain half of the is_paused auto-clear

Date: 2026-07-27

Context — a real desync, found on Test Pool 260616-base: admin pause returned 502 "On-chain pause operation failed" and could not be recovered from the UI. On-chain paused() was true while DB is_paused was false; pause() reverted EnforcedPause(). Trail: PAUSE (2026-07-03, on-chain + DB) → FREEZE (07-23) → UNFREEZE (07-27), with no UNPAUSE in between. The freeze's v3-78 auto-clear had turned the DB column off without touching the contract.

Decision: v3-78 auto-clear applies to the contract as well as the DB column. pools.post.freeze (freeze), .impairment (execute) and .wind-down (execute) call the shared clearOnChainPause(poolAddress, chainId) helper (lib/shared/contract/pause.ts), which reads the live paused() and sends unpause() when set:

  1. Escalation tx first, unpause() second. Unpausing first would leave the contract deposit-open if the escalation then failed — the 08 §A DB-only-pause gap. Safe because unpause() has no whenNotFrozen / whenActive guard (only pause() does), so it still lands after a freeze.
  2. The DB mirrors the chain, not the intent. is_paused = false is written only if the contract is actually unpaused; a failed unpause leaves it true (audited pause_clear_failed, success records unpause_tx_hash). A truthful double flag beats a lying single one — both block deposits and the higher state wins the display priority.
  3. Reads paused() from the chain, not the DB mirror, so an already-desynced pool self-heals on its next escalation.

Why: is_paused is on-chain SoT (08 §A: a DB-only pause lets an investor bypass via a direct whenNotPaused-gated deposit()), and OZ Pausable is an independent contract flag — PlatformPool.emergencyFreeze() sets only isEmergencyFrozen, and lifecycle changes don't touch Pausable either. So a DB-only clear strands the contract paused: deposits revert invisibly while the UI shows the pool as open, a later pause() 502s, and the UI offers no unpause (the DB says it isn't paused). Nothing self-heals it — the on-chain indexer does not mirror paused() back to the DB.

Result:

  • BE (apps/infra): new clearOnChainPause + OnChainPauseClearResult in lib/shared/contract/pause.ts (exported via contract/index.ts); pools.post.freeze / .impairment / .wind-down clear is_paused conditionally on the result and audit unpause_tx_hash / pause_clear_failed. tsc --noEmit green (deploy-pending — infra has no auto-deploy).
  • Already-desynced pools need a one-off POST /pools/{id}/pause {"paused": false} — unpause is always accepted BE-side and unguarded on-chain. Test Pool 260616-base (cb2a9e59…, pool 0x256910A6…, chain 11155111) is in this state; unpause() simulates OK.
  • Not changed: the pause endpoint still 502s on an EnforcedPause() revert rather than self-healing (an idempotent "already paused → sync DB" path was considered and deferred).

Refs: amends v3-78; 10-status-machines → Auto-clear; 08-smart-contracts §A; CLAUDE.md Auto-clear (v3-78). Source: CH (BE), 2026-07-27.

v3-93 — Epoch request window is a hard gate; funding date is admin-entered per cycle; cancel window = request window (amends v3-91) 🔧 Decided · contract redesign pending (CH)

v3-93 — Request-window gate, admin-entered funding date, cancel window

Date: 2026-07-27 · Status: decided, not yet implemented — amends the v3-91 target design. On-chain behaviour is still the as-is model in 07-redemption.

Context — v3-91 contradicted itself on the freeze. v3-91 specifies Model B (a short requestWindowDays window followed by a gap in which requests are not accepted) but described the freeze mechanism as "split the accepting cursor from the settlement cursor — requests after cutoff enroll into the next epoch." A lone cursor split never rejects anything: acceptingEpochId = floor((now − anchor) / duration) + 1 is defined for every instant, so every request lands somewhere and requestWindowDays is never enforced. That is Model A (always-accepting) with a relabelled boundary — exactly the gap v3-91 §A-(e) set out to close.

Decision:

  1. The request window is a hard gate, and it is the freeze mechanism. requestRedemption reverts (RequestWindowClosed) outside [windowOpen(N), cutoff(N)). Only the lower bound needs checking — the N formula already guarantees now < cutoff(N). The cursor split stays but is demoted to a late-settlement safety net: if a cron delay leaves epoch N unsettled when the N+1 window opens, new requests still enroll in N+1. It is no longer the thing that achieves freeze.
    • Why the window is load-bearing, not cosmetic: it paired with Rule 1 (yield stops at request) — under always-accepting, an investor who requested just after a cutoff waited duration + recallLeadDays (≈ 40 days on monthly) earning nothing, and the window bounded that to the 14–17 days the v3-91 presets quote. ⚠️ That reason is gone with v3-131 (3): the wait accrues. What the window still buys is a bounded, predictable queue and the griefing defences below — no longer protection from a forfeit.
  2. One reference point per cycle — the admin-entered funding date. fundingAnchor is not a fixed calendar rule. The publisher tells the admin its settlement date off-chain and the admin enters it each cycle (the §3 semi-auto flow); the publisher has neither pool-creation rights nor an on-chain date setter, so the confirmed FM wallet model (FM signs depositYield / fundRedemption only) is untouched. Each cycle stores its confirmed fundingDate and derives the rest — cutoff(n) = fundingDate(n) − recallLeadDays, windowOpen(n) = cutoff(n) − requestWindowDays, settlement allowed from fundingDate(n). If the admin enters nothing, C2 fail-open supplies previous fundingDate + scheduleType period. Consequence: the contract never does calendar arithmetic — no on-chain date library and no pre-baked schedule array are needed, and scheduleType shrinks to "the period the fail-open default advances by". (Two stale statements corrected here: the §3 setting table read "month-end / quarter-end · fixed reference day", and v3-91's §9-4 sketch anchored on the cutoff, off by recallLeadDays from the value the pool stores.)
  3. The semi-auto funding-date knob is free only until that cycle's request window opens. The §3 operating flow already puts the admin's confirm/adjust step before the window opens ("approve → window opens"), so this pins the boundary rather than adding a rule. Before windowOpen(n) the whole cycle re-derives normally; after it, an adjustment (the common case — the publisher is late) moves settlement only, and the window, cutoff and confirmed demand are frozen. Two failures this blocks: adjusting after the window opens rewrites a deadline investors are already looking at; adjusting after the cutoff re-opens a closed window and changes the amount already notified to the publisher. Adjustment is delay-only — pulling the date earlier would shrink the cutoff→funding gap below recallLeadDays and eat the publisher's recall time. Implementation: the operator knob is a standalone settleAfter[id] (earliest settlement time — the free setter of v3-91 §10-5) and is not an input to the cutoff derivation.
  4. Cancel window = request window. cancelRedemption carries the same gate as requestRedemption; both open and close together. After cutoff a cancel would falsify the total already sent to the publisher (over-recall), and after settlement it would break the settlement-reservation invariant. Rolled-over demand is not trapped — a partially-filled investor cancels in the next request window, so the restriction is scoped to the epoch, not to the request. This also closes two griefing paths for free (bulk request just before cutoff → notify → cancel, forcing the publisher into an early asset recall; and squatting the carry tier of the C8 FIFO ladder). No BE change — on-chain cancel is verified first and then mirrored. ⚠️ The UI instruction here is superseded: it required telling the investor that escrow-period yield is not refunded, which was true only under C3+I. Since v3-131 (3) shipped, a cancellation credits the entire wait, and to now rather than to the last settlement — a cancellation prices nothing, so there is nothing to stop the clock at.

Open items — all six closed the same day by v3-100: leftover epochFundTopUp[id] after over-funding; whether admin rejection carries the cutoff gate; a regulatory forced-cancel exception path; demand == 0 epochs; instant PENDING_RESERVE cancel symmetry; and pending epoch demand at WIND_DOWN.

Refs: amends v3-91; 07-redemption → Epoch-Based Redemption; 17-changelog v3-93. Source: JY review of the Notion "Epoch Redemption" handoff, 2026-07-27.

v3-94 — Pause is manual-only; overdue never auto-pauses. Auto-pause requires pause provenance first 🔧 Decided (as-is) · auto-pause open

v3-94 — No auto-pause on overdue; prerequisites if we build it

Date: 2026-07-27

Context — the admin UI announced a feature that does not exist. The pool-detail banner read "This pool is automatically paused due to overdue yield distribution. Resume manually after distributing yield." whenever is_paused && yield_overdue. An audit of every write path found no such mechanism:

  • is_paused = true is written in exactly two places: pools.post.pause (an ADMIN / OPERATOR endpoint) and pools.post.create (initial value). No scheduler, worker or indexer sets it.
  • On-chain PlatformPool.pause() is onlyRole(PAUSER_ROLE) whenActive whenNotFrozen — a human-signed path only. The one genuinely automatic halt in the system is the NAV staleness / deviation hold inside executeEpoch (v3-32), which blocks epoch settlement, not deposits, and sets no flag. circuitBreakerTripped is likewise manual (tripCircuitBreaker, PAUSER).
  • pools.scheduler.yield-due only recomputes next_yield_due, sets the yield_overdue boolean, and queues notifications (yield_distribution_due once on the transition; yield_distribution_overdue after a 3-day grace, re-sent at most every 7 days). Every yield_overdue consumer is a badge or a filter.
  • Redemption has no overdue concept at all. redemption-requests.scheduler.epoch-execute triggers settlement and raises admin notifications for anomalies the contract already guards; .partner-funding sends funding reminders. Neither judges lateness.

So the banner fired on a coincidence — an operator-paused pool that also happened to be behind on yield — and described it as cause and effect. Read as a safety net, it invites the opposite of safe behaviour: an operator who believes overdue pools self-pause will not go pause one.

Decision (as-is, effective now): pause is manual-only, and no status copy may say otherwise. Fixed in both apps (v3-95). The yield-overdue signal stays what it is — a notification plus an ops flag (/pools YIELD_OVERDUE / HAS_ISSUES filters, OverdueBadge, the Overview money-action alert with a Distribute CTA).

Decision (prerequisite, if we build auto-pause): an automatic pause cannot be built on the current schema, because is_paused is a bare boolean with no provenance. Two unresolvable collisions follow from that alone:

  1. Resume ambiguity. If the system pauses, who un-pauses? Manual resume means an FM who distributes still waits on an operator (and the operator sees no reason recorded). Automatic resume on distribution means a pause an operator set for an unrelated reason gets silently lifted by an FM's payment — the system reverting a human decision it cannot see.
  2. Escalation re-entry. v3-78 / v3-92 auto-clear is_paused (DB + on-chain) when a pool escalates to freeze / IMPAIRED / WIND_DOWN. After an operator escalates and then recovers the pool to ACTIVE, the overdue condition still holds, so the next sweep would immediately re-pause a pool a human just deliberately reopened.

Therefore: pause_reason (enum: MANUAL / YIELD_OVERDUE / …) + paused_by are a hard prerequisite, not a nice-to-have. Build those first or do not build auto-pause.

Open — to decide only if we proceed (recommendation attached, none of these is settled):

  • (a) Is the predicate even sound? yield_overdue is not a reliable distress signal today. The anchor is last DISTRIBUTED distributed_at ?? start_date and is deliberately never rolled forward, so a pool that has not yet had its first distribution goes overdue at start_date + interval and stays overdue — including healthy new pools whose first payment is legitimately in flight. Auto-pausing on the raw flag would pause exactly the pools we most want open. Recommendation: require at least one prior DISTRIBUTED distribution before the pool is eligible at all.
  • (b) Threshold. The existing YIELD_DUE_GRACE_DAYS = 3 is a notification cadence and far too short to halt capital on. Recommendation: a separate, longer threshold expressed in missed cycles (e.g. 2 consecutive) rather than days, so it scales with yield_frequency instead of punishing monthly pools 10× harder than annual ones.
  • (c) Who signs. Pause is an on-chain tx. Precedent exists — pools.scheduler.lifecycle signs with the single admin key, sequentially, to avoid nonce collisions — but the sweep must replicate pools.post.pause's guards (skip non-ACTIVE, skip frozen) or pause() reverts whenActive / whenNotFrozen, and must not desync the DB on a failed tx (v3-92 rule 2).
  • (d) Scope. Deposit pause (capital in) or something stronger? Recommendation: never stronger — an FM being late is not a reason to trap existing investors' exits.
  • (e) Redemption overdue. Undefined today, and it needs a reference point to be late against. v3-93 supplies one for the first time (the admin-entered per-cycle funding date), so this is worth revisiting after v3-93 lands, not before.
  • (f) Whether the answer is a pause at all. Recommendation: probably not. The failure mode is an FM missing a payment; the levers that address it are notification escalation (built), the ops queue (built), fundingRestricted hold-back, and IMPAIRED with its 7-day timelock and human judgement. A silent robotic pause adds a state with no author, which is precisely what the two collisions above are made of.

Result: copy fixed in apps/web + apps/admin-web (v3-95); no schema, BE, scheduler or contract change. pools.scheduler.yield-due behaviour is unchanged and is now documented as notify-only.

Refs: 10-status-machines → Capability matrix / Status Banner Copy; v3-95; constrained by v3-78 + v3-92; (e) depends on v3-93; 17-changelog v3-94. Source: JY, 2026-07-27.

v3-95 — Status banner copy is a correctness surface: SoT table + five rules; fabricated investor drawer removed ✅ Decided

v3-95 — Status copy SoT + removal of unsupported claims

Date: 2026-07-27

Context. Auditing the v3-94 banner turned up the same class of defect across every status surface: copy that had drifted from behaviour, or had never matched it. Four findings, in severity order.

  1. 🔴 The investor paused-drawer was fabricated end to end (apps/web/shared/ui/PoolStatusAlert.tsx). It rendered a "Paused: {date}" line computed from Date.now() at mount (i.e. always today, never the real pause date), a "Review completion: Expected by {today + 45 days}" deadline, a "Status updates: Posted every Friday" cadence, an "Alternative: Structured wind-down with principal protection" outcome, and a "Your Investment Is Protected" block asserting principal is "secured in underlying assets" (pools may be UNSECURED), that yield "continues to accrue during review" (accrual is MANUAL_CLAIM; there is no automatic accrual), and that "withdrawals resume when pool reopens" (they never stopped). It also carried two dead buttons ("Read Official Notice", "Contact Support"), violating CLAUDE.md rule #2. None of it came from data; all of it reads as commitment.
  2. 🔴 The paused banner told investors their exits were blocked"Your investment is protected. Withdrawals resume when pool reopens." is_paused blocks capital in only; redemption-requests.post.create documents redemptions as processing normally under a soft pause. The copy inverted the flag's meaning and added a protection claim.
  3. The frozen banner read as open-ended"All activity on this pool is paused. Please check back later." A freeze is asymmetric and self-expiring (v3-28): value-out reopens 72h after freeze_started_at, and the whole freeze lapses at 7 days with no transaction. freeze_exit_window_ends_at / freeze_auto_expires_at were already on the read model and simply unused.
  4. is_paused also blocks reinvest, which no admin copy mentioned (yield.post.reinvest rejects on is_paused; the contract comment scopes pause to "deposits and reinvest — capital-in only").

Decision. Status copy is treated as a correctness surface, not decoration — a wrong status line is a defect of the same class as a wrong nav_per_token, because it is the investor's only account of whether they can get their money out. 10-status-machines → Status Banner Copy becomes the SoT table for every state's title and text in both apps, governed by five rules: (1) never describe a state as automatic unless a scheduler actually writes it; (2) no protection / guarantee / recovery language, ever; (3) no invented dates or cadences — every date shown traces to a column, and a Date.now()-derived date is a fabrication, not a default; (4) name the axis (capital in vs value out), since "Paused" alone reads as "my money is stuck"; (5) every affordance in a banner or drawer must work.

Result:

  • apps/web: PoolStatusAlertpaused copy now states deposits + reinvest are paused and position / redemptions / withdrawals / claims are unaffected; frozen takes optional freezeExitWindowEndsAt / freezeAutoExpiresAt and states the real reopen + expiry timestamps (falling back to "72 hours" / "within 7 days" wording, never "check back later"); pool.$id.tsx passes both. The drawer is rebuilt as what is paused · what continues · good to know — no dates, no outcomes, no protection block — and drops "Read Official Notice" (nothing to open) while "Contact Support" becomes a real mailto: to SUPPORT_EMAIL.
  • apps/admin-web: the pool-detail banner reads "Deposits are paused on this pool. Redemptions, withdrawals and yield claims continue." with the overdue note demoted to a separate sentence; "Resume Pool" → "Resume Deposits" (a pause never stopped the pool); the Deposit Pause control row + confirm dialog now say capital in — deposits and reinvestment and that nothing auto-pauses.
  • Docs: the capability matrix rows for Paused / Frozen corrected (reinvest; the 72h asymmetry the matrix previously flattened to a blanket ❌), plus a new Frozen is asymmetric and self-expiring subsection.
  • tsc -b green for both apps. No schema, BE or contract change.

Resolved 2026-08-04 — wind-down "reserve" wording is now "available liquidity". ⚠️ The paragraph below is the pre-decision record; see 10-status-machines → Status Banner Copy for what shipped. Both apps described the wind-down payout as pro-rata from reserve, which matched the contract when this was written (executeWindDown: navPerToken = reserveBalance / totalSupply). The reasoning for not touching the copy was that reserveBalance grows only via the reserveBps split on deposit / reinvest, so no path existed for a partner to pay recovered capital in, and under the reserve-zero launch assumption the formula yields NAV 0 — the copy was therefore truthful and the recovered-capital model was decided but unimplemented. v3-100 changed the numerator: it is now reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted, and totalEpochTopUp is fed by the partner's own fundRedemption — so recalled capital does reach the numerator and the "no path exists" premise is false. This does not automatically make "recovered capital" the right wording (in a wind-down the partner is unresponsive by definition, so a reserve-zero pool still prices at or near zero unless they actually funded), but the condition this decision was waiting on is met. Product call, not a doc fix. → Called on 2026-08-04: switch to available liquidity across every surface, keep the "may fall well short of your principal" caveat, and quote no formula in investor copy.

Refs: 10-status-machines → Status Banner Copy; implements the copy half of v3-94; v3-28 (freeze asymmetry); CLAUDE.md Pool Detail Banners + rule #2; 17-changelog v3-95. Source: JY, 2026-07-27.

v3-96 — Recording yield = distributing it; recovery is status-scoped; distribute tx_hash persisted before finalize ✅ Shipped (admin) · infra deploy-pending

v3-96 — Yield record/distribute is one-shot; safe recovery model + orphaned-PENDING fix

Date: 2026-07-27

Two decisions share the number v3-96 — this one (yield) and the copy-guard one — because both shipped in that release on 2026-07-27. The version number is deliberately kept on both; only the anchors are split. Link to #v3-96-yield for this card, #v3-96 for the other.

Context. The admin Yield page presented Record and Distribute as two steps and surfaced a "Ready to distribute" queue of PENDING records, implying a deliberate record-then-distribute pipeline. The backend does not work that way. yield-distributions.post.create runs the whole distribution in the same request: the server path (deposit:false) inserts then calls runYieldDistribution and returns DISTRIBUTED (or FAILEDrunYieldDistribution marks the row FAILED on an on-chain distribute failure); the FM path records PROCESSING with the client deposit_tx_hash, and POST /{id}/distribute verifies that deposit landed on-chain, then finishes. There is no async worker that consumes PENDING rows. So a lingering PENDING is not a "waiting" state — it is a distribution that started and did not finish. Two further mismatches: the modal blocked recording when no fee config was set (fees are optional — a null net_yield_fee_config means net = gross, and fee rates are editable even while ACTIVE per v3-69), and the action was labelled "Record" when it distributes.

Decision.

  1. Recording yield is distributing it (one-shot). No deliberate "record now, distribute later" state exists; the action and labels are "Distribute". Fees are never a prerequisite — no fee config means 100% of gross to investors, with a live link to set fees (editable at any lifecycle stage).
  2. Recovery is scoped to what the backend safely supports, because a blind retry can double-distribute. POST /{id}/distribute accepts PROCESSING only (it verifies the FM deposit on-chain and is idempotent once DISTRIBUTED). So: PROCESSING → "Distribute" (finish); FAILED → "Re-distribute", which opens a fresh record (the failed attempt never distributed on-chain, so re-creating is safe); PENDING → no self-serve action — a lingering PENDING is a finalize-DB orphan that may already have distributed on-chain, so any in-place retry risks paying twice. PENDING surfaces read-only.
  3. Distribute tx_hash is persisted before finalize (root-cause fix). runYieldDistribution now writes tx_hash + tx_submitted_at (status PROCESSING) immediately after distributeYield succeeds on-chain, before the enrichment/finalize UPDATE. That UPDATE was previously the only place tx_hash was written, so a crash / DB failure after the on-chain distribute orphaned the row at PENDING/PROCESSING with no tx_hash — and the indexer (writeYieldDistributed) reconciles by tx_hash, so it could never heal the row (and would insert a duplicate DISTRIBUTED row). With tx_hash written early, the indexer reconciles any such row to DISTRIBUTED, so no orphan lingers and no retry can double-pay. On-chain calls and amounts are unchanged.

Result.

  • apps/admin-web (Yield page): action labels + toasts say "Distribute"; the record-modal gate is wallet-only (fund_wallet pools need the FM wallet connected to sign depositYield — fees no longer block); a Calculation section itemises Pool TVL · APY · Yield period → Estimated yield, then Gross → Fee (subtitle with a per-leg breakdown, live link to the pool's yield-fees step, "none" when unset) → Net to investors; recovery actions per status as above; the Overview leads with estimated yield across overdue pools and the all-distributions total uses counts.ALL. tsc -b green.
  • apps/infra: the tx_hash-persist fix in runYieldDistribution. Deploy-pending (infra has no auto-deploy).

Refs: 04-pool-models → Yield; 23-money-path; fees editable per v3-69 + v3-70; 17-changelog v3-96. Source: JY, 2026-07-27.

v3-96 — Copy gets the failure signal it never had: copy modules + a build-gating guard + rule 2-b ✅ Decided

v3-96 — Structural fix for unverified copy

Date: 2026-07-27

Two decisions share the number v3-96 — this one (copy) and the yield one — because both shipped in that release on 2026-07-27. The version number is deliberately kept on both; only the anchors are split. Bare #v3-96 resolves here.

Context — why v3-95 alone would not hold. v3-95 corrected the wrong strings and wrote down the right ones. That fixes the instance, not the mechanism: nothing stopped the next unverified line from shipping the same way. Naming the mechanism precisely matters, because the obvious diagnoses are both wrong.

It is not carelessness, and it is not a review gap. It is that copy is the only surface in this codebase with no automatic failure signal. Types fail tsc, schema drift fails a query, contract changes fail a test — but fabricated copy compiles, typechecks, renders, passes review and ships. Both v3-95 defects did exactly that and were green the whole time.

And the failure mode is specifically adversarial to review: when a component has a text slot and no spec, the output that gets written is "a sentence that does not look wrong". Date.now() + 45 days rendered as "Review completion: Expected by …" is not a random error — it is what a convincing mockup looks like, and mockup copy is written to be convincing. The Aset FE was ported from mockups, so realistic fake content arrived with no marker distinguishing it from product.

So the underlying problem is: the absence of a spec is invisible when the copy is written and expensive when it is reviewed. Every fix below makes that absence visible instead.

Decision — three mechanisms, ascending in how hard they are to ignore:

  1. Status copy lives in copy modules, not inline in componentsapps/{web,admin-web}/app/shared/copy/status.ts. Someone who knows the backend can audit the entire surface in one file instead of hunting through JSX; a copy change reads as a copy diff; and an agent editing a component cannot quietly invent a line, because doing so means touching a module whose diff is loud. Each string carries the code path that enforces its claim (is_pausedredemption-requests.post.create.ts, freeze asymmetry → PoolCommonLib.checkExitNotBlocked, …) — so a reviewer follows the citation instead of reconstructing the behaviour, and an author who cannot produce one has a signal they are guessing.
  2. scripts/check-copy.mjs gates the build. Bans affirmative guarantee / protection language, invented deadlines and weekday cadences anywhere in app source, plus any clock read inside a copy module (where strings are static, so Date.now() is by definition inventing a value). Deliberately a build step, not a lint rule: CI runs build, not lint (deploy-*.yml run pnpm --filter … build:dev), and pnpm lint currently fails in both apps on ~65 pre-existing errors — an eslint-only gate would have gated nothing until that backlog cleared. Wired into build / build:dev / build:prod in both apps, plus a standalone pnpm copy-guard.
    • Negated disclaimers pass by design. "target returns are not guaranteed" is required copy, so the guard tests for a negator in a short window before each match rather than relying on adjacency — adjacency alone breaks on "not a guaranteed amount" (a real line in epoch-countdown.tsx, which the first draft flagged). Verified with 10 regression cases: 8 known-bad strings from v3-95 all fire, both negated forms pass.
    • Escape hatch: copy-guard-allow: <reason> on or above the line, reason mandatory — a bypass should be recorded, not hard.
  3. CLAUDE.md rule 2-b extends rule #2 from buttons to text. Rule #2 already established the norm that an unwired button must be disabled rather than fake. 2-b applies the same principle: copy asserting product behaviour that has not been verified must be a TODO(copy) placeholder plus a surfaced question, never plausible text. Framing it as an extension of an accepted rule rather than a new one is deliberate — it is far likelier to stick.

Also mirrored as eslint no-restricted-syntax scoped to app/shared/copy/** for editor feedback (the fast half; the script is the enforcing half).

Why these three and not more. Two other candidates were considered and not adopted now: adding lint to CI (blocked on the 65-error backlog — a separate cleanup, and forcing it now would either break deploys or balloon this change), and requiring mock content to carry a sentinel so it cannot survive a mockup→FE port silently (correct, but it belongs to the mockup workflow, not this repo's build).

Result:

  • New: apps/web/app/shared/copy/status.ts (9 variants + the paused drawer), apps/admin-web/app/shared/copy/status.ts (pause banner + the four control levers with their confirm text), scripts/check-copy.mjs.
  • PoolStatusAlert keeps presentation only (tone, icon, layout) and holds no strings; the drawer renders from PAUSED_DRAWER; admin pool-detail + pool-controls reference POOL_PAUSE_BANNER / POOL_CONTROL_COPY (18 call sites).
  • tsc -b green both apps; copy-guard clean over 331 files; full build:dev green both apps with the gate active; eslint rules verified to fire on a planted bad string.
  • No schema, BE or contract change.

Open: the same defect class almost certainly exists in the surfaces this pass did not audit — confirm dialogs beyond the pool controls, notification email copy (lib/shared/notifications/copy.ts), disabled-button tooltips, error and empty states. Notification copy is the highest-consequence of those (it leaves the platform and cannot be recalled) and confirm-dialog copy the second (an operator reads it immediately before an irreversible action). Neither is covered by the guard's current pattern set beyond the guarantee words.

Refs: makes v3-95 enforceable; 10-status-machines → Status Banner Copy; CLAUDE.md rule 2-b; 17-changelog v3-96. Source: JY, 2026-07-27.

v3-141 — The offering close gets its own column, because end_date was answering "subscriptions are over" and "the term is over" with one number ✅ Shipped · 0204 + sweep + wizard · deployed 2026-08-21 · contract gap closed

v3-141 — `subscription_end_date`, and the CLOSED path carries traffic for the first time

Date: 2026-08-20 · Status:shipped (migration 0204, the lifecycle sweep's two passes, POST /pools + PATCH /pools/{id} + the publish gate, and the create wizard's step 5). Deployed 2026-08-21: the migration is recorded in schema_migrations, the API stack carries subscription_end_date (confirmed live on GET /pools), admin-web is out, and the contract gap closed in two rounds (v3-142 has the second). The reasoning below is kept as written, including the parts that describe the gap while it was open.

Two readers, one column, and they agreed only by accident. pools.end_date was read as the subscription period is over by pools.scheduler.lifecycle pass 3 (ACTIVE → CLOSED) and as the term is over by resolveMaturityAt, which takes it as the second-choice maturity for any pool with no on-chain maturity_date yet. Both were right about the same number because the create wizard computed end_date = start_date + maturity_days. So a pool took deposits until the day it matured: money arriving on the last afternoon was invested for one day and then joined the queue for principal repayment. Nothing failed, no screen was wrong, and every field held a defensible value.

🔴 end_date could not simply be moved earlier. Because resolveMaturityAt reads it ahead of start_date + maturity_days, shortening it on a published-but-undeployed pool shortens the pool's term, and the yield-due cap in resolveNextYieldDue then drops obligations along with the date — an unpaid period stops appearing on any screen or in any notification rather than failing anywhere. end_date keeps meaning maturity; the offering close is a new column, and the migration says so in the one place a future reader will look.

Pass 3 of the sweeper had never run, so this is a path being switched on rather than adjusted. It re-reads ACTIVE pools after the maturity pass, and both passes compared the same date, so a FIXED_TERM pool always left as MATURED and was gone before pass 3 looked. CLOSED was reachable only by hand (POST /pools/{id}/close). Switching that path on exposed what pass 2 had been able to assume:

🔴 The maturity sweep selected ACTIVE only, so a pool that closed early could never mature. Penalty-free redemption, the holder-facing copy, the notifications and the admin badges all key on MATURED, not on the calendar, so a pool that closes its offering before its term sits in CLOSED past maturity and every one of those reads the wrong thing. ⚠️ It does NOT strand anyone's money, and the earlier wording here said it did. The on-chain penalty decision is PoolCommonLib.isBeforeMaturity (block.timestamp < maturityDate) and reads lifecycleStatus only for WIND_DOWN / IMPAIRED, while REDEEMABLE_LIFECYCLES already contains CLOSED — so a matured-but-CLOSED pool still redeems penalty-free. What breaks is the status layer: the pool_matured notifications never fire, badges say Closed, partial redemption stays allowed where MATURED forces full-only, and resolveNextYieldDue's maturity cap never engages so the pool keeps accruing a due date and an overdue flag for ever. The filter is now lifecycle_status IN (ACTIVE, CLOSED), narrowed in JS to a CLOSED pool that actually carries a subscription_end_date — which is exactly the set pass 3 produces. 🔴 That second condition is not tidiness. Closing by hand is explicitly reversible ("a judgement call about market conditions, not a terminal fact", per pools.post.close), and reopen takes such a pool back to ACTIVE; sweeping it to MATURED would delete that option, since MATURED → ACTIVE is in no transition table. On a pool with no pool_address nothing on-chain gates the write, so it would happen silently and at once. It would also catch a pool closed from UPCOMING, which never opened. Widening the status filter alone is a behaviour change to pools that predate 0204, and the order says to leave those alone. The pass order is unchanged: the offering close comes first in time, so a pool now walks ACTIVE → CLOSED on that date and CLOSED → MATURED at its term, one transition per date instead of two readings of one. Pass 3 still refuses the reverse, for the reason it always did.

The chain allows CLOSED → MATURED as of b619d9a. It did not when this card was written, and the gap could not be closed off-chain: LifecyclePolicy.isValidTransition treated CLOSED as terminal for the admin setter, and transitionPools writes on-chain first, so the call reverted and the DB never moved either (v3-92). The fix took the DAG's dead edge out along with it — MATURED → CLOSED was allowed on-chain and used by no off-chain path (pools.post.close has CLOSEABLE = [ACTIVE, UPCOMING]; the sweeper's close pass queries ACTIVE only), so keeping it would have made CLOSED ↔ MATURED a cycle. The invariant is now "CLOSED leaves only by maturing", and CLOSED → ACTIVE stays rejected because reopening is off-chain. No new enum value, no DB change, no off-chain change, ABI untouched (LifecyclePolicy is internal, so it inlines). Decision and cost comparison: Claude-Plan/Active/W9-D1-closed-terminal-decision.md; the naming study that ruled out a new lifecycle state is in its §6.

⚠️ Clones cutoff. The rule lives in the pool implementation and pools are clones, so only pools created after the new implementation and factory go live carry it; earlier pools keep CLOSED as a terminus for ever. Harmless on this leg, because none of them has a subscription_end_date and so none can reach CLOSED — but it is why the implementation should ship before the W9 wizard is used to create real pools. PlatformPoolFactory.poolImplementation is immutable, so a new factory is part of that deploy and FACTORY_ADDRESS_{chainId} has to move with it. ✅ Shipped 2026-08-20 (impl 0xCfe751fd…, factory 0x2DA6f9c6…) and superseded a day later by v3-142 (impl 0x405E89Ec…, factory 0x206fC348…), so there are now two cutoffs. Audited 2026-08-21: none of dev's nine deployed pools sits on the current implementation, and nothing off-chain records which one a pool came from (apps/contract/sepolia.md → Live pool generations).

The ceiling is one YIELD payment, and it lives in the wizard. subscription_end_date <= maturity − one yield period (30 days monthly, 90 quarterly, from firstYieldIntervalDays) blocks the step rather than warning, because the value is written into the deployed pool and the harm lands on an investor who had no say in it. A softer band, maturity − three yield periods, warns without blocking and names the count the last investor in would receive.

  • 🔴 Not the epoch cadence. The product has two unrelated cadences: yield on a calendar axis (30 / 90) and post-maturity repayment on a fixed-day axis (28 / 84, EPOCH_CADENCE_DAYS). The rule is about whether a late investor collects any yield, so it is the yield number. Copy must not say "cycle" for the same reason: an operator reads that as the repayment cadence, and a test asserts no message contains the word.
  • 🔴 Not in the endpoint. POST /pools checks only the relation (start_date < subscription_end_date), and the publish gate adds subscription_end_date < end_date — the parts that are wrong on any reading. Restating the yield-period rule server-side would be a second copy of a calculation the two sides would eventually disagree about, and it needs the cadence and the maturity model to evaluate.
  • 🔴 The axis is maturity_model, not redemption_type (corrected 2026-08-20, after the first implementation shipped the narrower reading and the field failed to render on the pool an operator was looking at). Every shape is asked; the shape decides the RULE. A pool with a maturity: required, ceiling, warning, timeline bar. OPEN_ENDED: optional, no ceiling (nothing to measure against), summary line only. Scoping to FIXED_MATURITY protected two of the four shapes that can hurt a late investor and cost more than it protected — "redeem on request" does not make a late deposit safe, because lockup_days > 0 blocks redemption outright during the lock-up (v3-76), and an on-request pool could not set a close date at all, so it went on taking deposits until the day it matured. §3-3⑥'s own reason is about the BLOCKING ceiling; a visible field blocks nothing. This supersedes the work order's §3-3⑥ and §5.

The wizard's step 5 became Schedule & redemption. The start date moved into it from step 6, where it had been sitting next to the publish button: the one date that decides when the pool opens and when the yield cadence starts counting was being answered after every question that depends on it, and the offering window had nowhere to be asked at all. Step 6 keeps a read-only echo of all three dates. A timeline bar at the top of step 5 draws offering / yield-only / repayment to scale with the shortest effective term bracketed under it, because the original defect was invisible in numbers and is unmissable as a bar with no middle. Lock-up is deliberately not a segment — it runs from each holder's own deposit, so it has no single place on a pool-wide axis.

🔴 4 before 5 is a dependency, not a layout preference. The ceiling is computed from the yield cadence chosen on step 4. Swapping the two steps would ask how long the offering may stay open before anything knows what a yield period is.

🔴 Both pool read selects carry the column, and that is not paperwork. LIST_SELECT and DETAIL_SELECT in pools.get.list are explicit allow-lists (security S-35), and they are the only source for PoolRow. A column missing from them arrives as undefined, the mapper turns it into null, and nothing fails: the pool overview reads "Runs to maturity" on every pool including the ones that close early, the Configuration tab's Subscription Close sits blank on a pool that has one, and the pool-edit form (which seeds that field from this value and sends it back) writes null over the stored date on the next save. ⚠️ Deploy order: apply 0204 before deploying these Lambdas — Supabase rejects the whole select if a listed column is absent, so GET /pools/{id} would 500 for every pool, the same hazard 0111 carries a note for.

Labels changed wherever the old column was rendered. "End date" is gone as a label: the pool-edit form, the Configuration tab and the pool overview now say Maturity, with Subscription close beside it. The label was most of the defect — an operator reading "End date" on a pool still taking money set it to the day they wanted subscriptions to stop, and moved the term instead.

🟡 The whole ceiling is a stopgap. It exists because maturity is a single pool-wide date. Once maturity is per-position (D2), each holder's term runs from their own deposit, a late entry costs its holder nothing, and there is nothing left for the ceiling to protect. Delete features/pool-form/offering.ts then rather than porting it; the module says so at the top.

Existing pools are not backfilled (JY, 2026-08-20). Copying end_date in would set every offering close to the maturity date, which is the conflation being unwound, and would light up a sweep path those pools have never been through. Regression checking on deployed pools was explicitly waived.

🔴 A second absolute date means the deploy has to re-read them. Maturity is not stored, it is derived at the deploy instant (deploy_time + maturity_days, migration 0161), while start_date and this column are absolute dates the wizard wrote. So the two behave in opposite ways when a draft waits: maturity slides, the offering close does not, and the gap between them is not stable. Deploying past the offering close ships a pool whose offering is already over — pass 3 closes any ACTIVE pool whose subscription_end_date is in the past, hourly, so the pool would be deployed, visible, on-chain and shut to new money before one deposit landed. Nothing caught it, because every date check compared the pool's dates against each other, and against each other they stay perfectly consistent. Today was the term the pool had never been measured against.

Decided: the publish path and the retry-deploy path both re-read the dates and refuse a stale offering close (pools.post.lifecycle), and both show the operator which dates the deploy decides and which are already fixed before they confirm. It refuses rather than clearing the date, because "raise until the term ends" (NULL) and "close on a later day" are both legitimate and they are different decisions, and the operator holds the reason. A slipped maturity and a start date that has gone by are cautions, not blocks — both are legitimate things to want, and neither is silent once it is written on the screen. 🔴 The retry path is where the window is widest and it had no checks at all, because the handler skips every publish validation for a pool that is already published. Precedent: the W2-4 guard in pools.worker.deploy already refuses an epoch funding anchor that no longer clears the drifted maturity, on the reasoning that the wizard's own floor counts from today and is therefore an estimate; this is that reasoning applied to the dates themselves. ⚠️ The deploy still does not rewrite end_date, so a drifted pool's Configuration tab shows a maturity it does not honour — recorded in 04-pool-models → maturity_days rather than fixed, because rewriting a stored date at deploy is its own decision.

Refs: corrects the pools.end_date note in 11-db-schema → v3-110 pool close; extends v3-110 B (the auto-close pass) and v3-132 (the post-maturity repayment pool this is scoped to); 10-status-machines → pool lifecycle · 11-db-schema → pools. Migration 0204. Source: JY (work order W9), 2026-08-20.

v3-142 — A closed offering can be reopened, and the chain had to be told ✅ Shipped · contract deployed 2026-08-21 · 0205 applied · infra deploy pending

v3-142 — `CLOSED → ACTIVE`, and what a reopen does with the date that closed the pool

Date: 2026-08-20 (decided) / 2026-08-21 (on-chain) · Status: ✅ shipped — LifecyclePolicy (3edf75b), new implementation 0x405E89Ec… + factory 0x206fC348… on Base Sepolia, and the admin reopen dialog.

🔴 The screens had been promising this since 2026-08-04 and it had never worked once. POST /pools/{id}/close with action: reopen sends setLifecycleStatus(ACTIVE), and LifecyclePolicy.isValidTransition had never accepted CLOSED → ACTIVE — not before v3-141's round, where it fell through to return false, and not after it, where the new CLOSED branch returned to == MATURED only. So every reopen on a deployed pool reverted and the handler answered 502, while the admin pool page rendered a Reopen button, the close confirmation said "You can reopen it while it has not matured", 10-status-machines carried a ✅ on the transition row, and the handler's own comment called a close "a judgement call about market conditions, not a terminal fact". Four places asserting a capability, none of which had read the transition table.

Decided: open the edge rather than delete the promise. CLOSED now has two exits, MATURED when the term ends and ACTIVE when the offering reopens. Three reasons. ① Reversibility is the designed behaviour — the handler says so about itself, and closing early is an operational call. ② Deleting the promise is not cheaper: it means editing the same three or four surfaces, and it removes an option operators are already being offered. ③ The alternative left the product with an endpoint, a button and a documented ✅ that all produced a 502.

⚠️ ACTIVE ↔ CLOSED is an intended cycle. The cycle that had to go was CLOSED ↔ MATURED, because it reverses a maturity, and v3-141's round closed it by removing MATURED → CLOSED — a dead edge no off-chain path ever used (pools.post.close has CLOSEABLE = [ACTIVE, UPCOMING], and the sweeper's close pass queries ACTIVE). MATURED is still where the admin setter ends.

Why it needed its own deploy. The decision landed after b619d9a was already written, so the 2026-08-20 factory shipped two of the three transitions and not this one. poolImplementation is immutable and pools are Clones, so there is no way to add an edge to an existing implementation: a new implementation, a new factory, a new FACTORY_ADDRESS_{chainId} and an API redeploy. It was worth doing at once rather than later because no pool had yet been created from the 08-20 factory, so nothing was permanently frozen without the edge.

🔴 A reopen keeps a FUTURE offering close, and the dialog now says which one. pools.post.close clears subscription_end_date only when it is in the past — necessary, because pass 3 would otherwise re-close the pool on its next hourly run and reverse the operator's action with no explanation beside it. A future date is kept on purpose: that pool was closed early by hand and still has a planned close ahead of it, and wiping a date the operator has not reached is the destructive reading. The consequence is that the pool closes itself again on that day, which the old confirmation did not mention at all — so a reopen behaving exactly as designed read as one that had not stuck. The confirm now names the retained date, or says the offering runs to the end of the term when there is none.

⚠️ The Clones cutoff is not harmless here, unlike v3-141's. A pool cannot reach CLOSED by sweep without a subscription_end_date, so the maturity leg only ever mattered to new pools — but any pool can be closed by hand, so the reopen leg is reachable on every pool that exists. An older pool can never reopen, and nothing off-chain records which implementation a pool was cloned from (pools has pool_address, deploy_tx_hash, deploy_status, created_at and no implementation column), so no screen can tell the generations apart and every pool gets the same button and the same promise. Audited 2026-08-21: none of dev's nine deployed pools can reopen, and seven of them predate the 08-18 round entirely (apps/contract/sepolia.md → Live pool generations).

Built instead of deferred: a per-pool generation signal (0205, JY 2026-08-21). The card first recorded this as deferred, on the reasoning that dev's pools could simply be recreated from the current factory. That was reversed the same day — dev's pools are being left alone, so the two generations coexist there, and prod could never have recreated its way out anyway. pools.pool_implementation is now written by pools.worker.deploy from factory.poolImplementation(), and canReopenOffering (@aset/types) turns it into an answer. The admin control shows the reason in place of the button rather than hiding the row: an operator can act on "this pool can never do it" and a row that silently loses its button reads as a permissions bug. pools.post.close refuses the same case with a 409 instead of letting the chain revert into a 502. 🔴 The rule is a CLOSED list of implementations that CANNOT, not an allow-list of ones that can. An allow-list has to be appended on every redeploy, and the first time somebody forgets, reopen silently stops being offered on pools that support it. The incapable set stopped growing the moment the capable implementation shipped, so it needs no maintenance and an unrecognised implementation is treated as capable — whose failure mode is a 502 (recoverable, and what we already had) rather than a capability withdrawn without anyone noticing. ⚠️ It does not make an old pool reopenable. Nothing can. The implementation is pinned at creation and there is no upgrade path, so the only route is a migration to a new pool, which for real holders is an on-chain act with its own consent problem. pool_factory is recorded alongside for a different question: which FACTORY_ADDRESS_{chainId} the backend was actually holding, because after a redeploy that env swap can be missed silently and new pools keep coming out of the old implementation with nothing looking wrong.

Refs: completes v3-141 (same round, third transition) · transition table + CLOSED row in 10-status-machines · LifecyclePolicy.isValidTransition, pools.post.close.ts, apps/admin-web/app/shared/copy/status.ts

v3-157 — The permissionless exit fallback is withdrawn ⚠️ Decided · guarantee withdrawn

v3-157 — `claimRedemptionFallback` is removed, which withdraws a guarantee rather than tidying a function

Date: 2026-08-31 (contract) · Recorded: 2026-09-04 · Status: ✅ removed from the contract; the docs pass that follows it is partial (see the last paragraph).

claimRedemptionFallback and approveRedemption are gone from PlatformPool. Both drew on reserveBalance, and v3-155 routes the reserve share to an external wallet at deposit time, so both were asking the pool to pay out of a balance it does not hold.

🔴 THIS IS A WITHDRAWN GUARANTEE, NOT A CLEANUP, and the contract says so where it used to live (PlatformPoolBase.sol:668). The fallback let anyone settle an instant-pool request FALLBACK_NOTICE_DAYS (7 days) after it was made. That is what made "an operator who stops answering cannot trap a holder on chain" (v3-28, v3-31) a property of the contract rather than a promise. It is now a promise.

Why it could not be kept as-is. With the reserve outside the pool, the available balance the fallback drew from is structurally zero: it could not have rescued anyone. Keeping the function would have left a guarantee that reverts.

What the exit right rests on now. The reserve wallet funding the request off-chain, through fundRedemption. That is a real trade and is recorded as one: a discretionary act by a keyholder replaces a permissionless one by anybody.

Unchanged: epoch pools. executeEpoch is still permissionless after the deadline and claimRedemption is still claim-on-behalf with the payout fixed to the requester, so a settled cycle pays out whether Aset acts or not. 🔴 Half of v3-31 survives; the instant half does not. Do not read "the permissionless exit is gone" as covering both.

Also removed: POST /redemption-requests/{id}/claim-fallback, the API mirror (15-api-reference), and the RedemptionFallbackClaimed event (08-smart-contracts).

⚠️ approveRedemption is in the same removal for a different reason and is not a withdrawn guarantee: it was an optional manual settle for the rare case where the reserve had grown through new deposits since the request. Deposits no longer grow it, so that case is not rare any more, it is impossible.

🔴 The docs are not fully caught up, and this card does not pretend otherwise. Six pages still describe the fallback in the present tense, one of them inside a section titled Non-custodial guarantees (08a-contract-reference §3.5, plus 07-redemption, 06-writedown-nav, 03-kyc-identity, 10-status-machines, and the FALLBACK_NOTICE_DAYS constant row). Listed here rather than left to be discovered, because a withdrawn guarantee that is still written down somewhere is the failure this card exists to prevent. Two contract comments are outstanding on the same axis (PlatformKYCSoulbound.sol:75, PlatformPoolBase.sol:128).

Refs: supersedes the fallback half of v3-28 and of v3-31 · caused by v3-155 · PlatformPoolBase.sol:660-673 · 09a-custody criterion #5 · 09-rbac · Source: CH contract round 2026-08-31, recorded by JY 2026-09-04.

v3-155 — The reserve moves out of the pool ✅ Decided

v3-155 — The reserve share routes to an external wallet, which turns `reserve_bps` from a retention ratio into a routing ratio

Date: 2026-08-27 · Status: ✅ decided, not built. Contract change; new pools only, because pools are Clones with the implementation fixed at creation.

The reserve share is routed to an external EOA (reserveWallet) at deposit time, and the in-pool reserveBalance counter is removed. Today deposit() splits the incoming stablecoin and retains the reserve leg inside PlatformPool; after this it leaves the contract in the same transaction, the same way the fund leg already does.

🔴 reserve_bps stops meaning "how much stays here". It becomes "how much goes to that address" — the same number, a different sentence. Every page that describes the reserve as "a percentage retained inside PlatformPool" is describing the deployed contract, not the decided one.

What this costs, and why it is still right. The pool can no longer see the money it is expected to pay from. Redemption availability today reads reserveBalance — a number the contract owns; afterwards the funding has to arrive as a top-up, which is the mechanism epoch pools already use. The gain is that the reserve and the fund wallet stop being two different kinds of thing: both become external balances an operator holds, and there is exactly one place a payout can come from.

⚠️ The consequence for wind-down pricing is a separate decisionv3-152. They ship together and neither makes sense alone.

⚠️ Every deployed pool keeps the old behaviour permanently. The diagrams, the worked example and the two-bucket framing in 23-money-path remain exactly true for them.

Refs: 23-money-path → §6 Reserve · 04-pool-models (deposit split) · pairs with v3-152 · supersedes the "reserve is not a wallet" half of v3-11

v3-154 — One rate field per accrual mode ✅ Decided

v3-154 — The rate field a pool carries is decided by its accrual mode, because a pool holding both has two answers to one question

Date: 2026-08-27 · Status: ✅ decided, not built. No migration yet — apy_rate is still NUMERIC NOT NULL in the schema.

apy_rate and accrual_rate_bps become exclusive, and which one a pool carries is decided by its accrual mode. A TARGET pool carries apy_rate and leaves accrual_rate_bps empty; a FIXED pool carries accrual_rate_bps and leaves apy_rate empty.

🔴 Both columns are NOT NULL today, so every pool carries both. One of the two is always a number nobody reads, and there is nothing in the schema that says which. The failure this prevents is not a crash — it is a screen or a report picking the wrong one and being plausible about it.

⚠️ They are not the same unit and never were. accrual_rate_bps is basis points (INTEGER, CHECK 0..10000); apy_rate is a percentage NUMERIC. A hurdle comparison against perf_hurdle_bps takes accrual_rate_bps directly, with no × 100 — see 26-glossary → ratios. Holding both invites exactly that multiplication error.

⚠️ accrual_rate_bps stays create-only. It is deployed into PlatformPool.initialize, which has no setter, because changing it would rewrite liability that has already accrued (v3-131 (3)). This decision changes whether the field is populated, not whether it can be edited.

What this does NOT decide. Not whether accrual_mode becomes a column. A pool's mode is which implementation it was cloned from and PoolCreated carries that address; a stored copy is a second answer that can disagree with its inputs — the same reasoning as v3-148 ①. What the create path persists is the rate, which is a create-time input, not a derived fact.

Refs: 11-db-schema → pools · 26-glossary → ratios · reads v3-131 (3) · same reasoning as v3-148

v3-153 — Interest is charged on paid-in capital ✅ Decided

v3-153 — Interest is charged on paid-in capital, so a write-down moves the NAV and does not move the bill

Date: 2026-08-27 · Status: ✅ decided, not built.

Interest accrues on principal, and principal means paid-in capital — money in, minus money returned as capital. It is not reduced by write-downs, and it is not the LP token count.

🔴 Two things are being separated that look like one. NAV answers "what is this holding worth". Accrual answers "what does the partner owe". A write-down is an event on the first; it is not a payment on the second. Reading them as one figure is how a defaulted pool quietly forgives the borrower.

The accounting is not novel. A lender's revenue recognition moves to amortised cost when an asset becomes credit-impaired (IFRS 9 §5.4.1(b)) — but the standard is explicit that the contractual interest obligation itself is unchanged when that happens. What we are building is the partner's debt ledger, not the investor's revenue statement, so the contractual basis is the correct one.

And it matches the preferred-return convention the product already uses: a preferred return accrues on contributed capital and is reduced only by a return of capital, never by a write-down.

The ledger already implements exactly this, under other names. s.totalDeposited is credited on deposit and debited only on a capital return; s.positions[u].totalInvested does the same per holder. Neither moves on a write-down. What is missing is not the accumulator — it is that the accrual engine currently prices off the LP token count instead of it.

Refs: 06-writedown-nav → the canonical formula · 26-glossary → principal term · PoolLedgerLib (totalDeposited, positions[u].totalInvested) · distinct from the NAV formula's total_deposited label

v3-152 — Wind-down stops recomputing the price ✅ Decided

v3-152 — Wind-down stops recomputing the price, because once the reserve is outside the pool there is nothing meaningful left to divide by

Date: 2026-08-27 · Status: ✅ decided, not built. Contract change; new pools onlyClones pin the implementation at creation.

executeWindDown stops recomputing navPerToken. The NAV the oracle last set becomes the liquidation price.

🔴 The recomputation was distributable / (totalSupply − settledUnclaimedLp), and distributable is the pool's own balance. Once the reserve routes to an external wallet (v3-155), that balance is no longer the money the pool is winding down — it is whatever happens to be sitting there. Dividing by it produces a number that looks priced and is not.

The honest figure is book value. What actually controls a holder's payout in a wind-down is the arrival of funding, not a recomputed price — and that has been true since epoch top-ups became the funding mechanism. Removing the recomputation makes the contract stop asserting something it can no longer observe.

⚠️ R10 is not being retired. It stays exactly correct for every pool already deployed, and those pools keep their implementation permanently. The settledUnclaimedLp getter, the pre-fix / now distinction and the worked example all continue to describe them. This applies to pools created after the change.

Refs: 06-writedown-nav → Writedown vs Wind-Down · R10 · narrows v3-100 (numerator) and v3-109 · pairs with v3-155

v3-151 — Reinvest is not offered ✅ Decided

v3-151 — Reinvest comes off every screen, and what actually closes the path is a column default rather than the missing buttons

Date: 2026-08-27 · Status: ✅ decided, not built — the CTAs are still on screen.

Reinvest is removed from the investor app and from admin, fund manager included. The allow_rollover toggle comes off the admin forms; the investor "Reinvest" CTA comes off the portfolio.

🔴 Removing buttons does not close a path — a default does. Pool.reinvest stays on-chain and POST /yield/reinvest stays in the API. What makes the call fail is pools.allow_rollover BOOLEAN DEFAULT false, which reverts RolloverDisabled before anything else runs. Stating it the other way round — "reinvest is gone" — leaves the next reader believing a caller cannot reach it, which is not true.

⚠️ Nothing is removed. The columns (allow_rollover, min_reinvest_amount), the endpoint and the contract function all stay. This is a scope decision about surfaces, not a deprecation.

⚠️ It supersedes the shape of BD5, not its reasoning. BD5 specified a manual Reinvest V1 with an investor CTA; the CTA is what is being withdrawn. BD5's other clause — "full yield only, no partial" — had already been overtaken by the code, which allows a partial up to the claimable balance; see v3-64 and YieldLib.reinvest ②/③.

Refs: 05-investment-lifecycle → Reinvest V1 Policy · 02-core-concepts → Yield Model · 11-db-schema (allow_rollover, min_reinvest_amount) · 24-field-governance · withdraws the CTA half of BD5

v3-150 — The settlement cap leaves the product surface ✅ Decided

v3-150 — The per-epoch settlement cap leaves the product surface and stays in the contract

Date: 2026-08-27 · Status: ✅ decided, not built — the admin forms and the investor copy still carry it.

redemption_gating_bps is deprecated — not included in MVP. It comes off the screens and the API. The contract and the column stay as they are: storage, the governance setter and its events are untouched.

🔴 This is a labelling change, not a removal. In MVP the value is always 0/NULL, which makes the cap term inert — available = reserveBalance + epochFundTopUp[id] with no ceiling applied. The settlement formula in 07-redemption is therefore left exactly as written; it describes a contract that still carries the cap.

Why it goes. For the pool shape this round is actually building — a repayment pool funded to a published schedule — a second cap can only break that schedule, which is why the create wizard already hides the field for that combination. Carrying a governance-timelocked parameter that no pool sets is a maintenance cost with no product behind it.

⚠️ The other gate is unaffected. An investor may only request as much as their own LP balance (InsufficientLPTokens). That is a separate rule and stays in force.

Refs: 07-redemption (settlement formula, left standing) · 11-db-schema · 24-field-governance · 04-pool-models (already hidden for this combination) · narrows v3-67

v3-149 — Instant redemption has no cancel ✅ Decided

v3-149 — An instant redemption cannot be taken back, and the on-chain branch that would allow it simply loses its caller

Date: 2026-08-27 · Status: ✅ decided, not built — the instant cancel path is still described as available.

Cancellation is an epoch-flow affordance only. An investor may cancelRedemption while QUEUED or PARTIALLY_FILLED; there is no instant cancel button in the investor app.

🔴 The instant branch of cancelRedemption stays on-chain. Out of MVP scope is not the same as removed, and writing it the second way would tell the next contract reader that a branch they can see does not exist.

Why the two flows differ. An instant request is either refused or paid; the window in which "cancel" means anything is a few blocks wide, and an affordance that narrow reads as a promise the product cannot keep. An epoch request genuinely sits — through a request window, then a settlement — which is what makes cancelling it a real action.

⚠️ Epoch cancellation is unchanged. It is possible only inside the request window, the locked LP is returned and epochTotalDemandLp is decremented. It does not return partner funding. The cancelled request persists as REJECTED with failure_type = INVESTOR_CANCELLED — there is no separate CANCELLED status.

Refs: 07-redemption · 10-status-machines (REJECTED + INVESTOR_CANCELLED) · RedemptionLib.cancelRedemption (instant branch retained)

v3-148 — The exit route is derived, never stored, and the judgement takes every axis 🔨 Built · front-end · jy/product

v3-148 — How a holding leaves is a derivation over the axes the pool already has, and the function that answers it must be handed all of them

Date: 2026-08-26 · Status: 🔨 built (apps/web, apps/admin-web). Front-end only: no column, no endpoint, no contract change. Nothing here changes what the product DOES — it changes where the answer comes from.

The product model was already written down; the client had not been given a way to read it. 07-redemption states the combinations redemption_type × epoch_duration_days produce and which two are unrepresentable. What no shared derivation stated was the consequence for a HOLDING: given those axes and today's date, how does this position actually leave?

🔴 The investor app had one verdict for a different question. LOCKED / EARLY / FREE answers "is this holder allowed out". Eight surfaces then read FREE as "right now, in full" — true of exactly one pool shape out of four. On a repayment pool FREE means "you may join the queue when a window opens"; on a matured lump-sum pool it means "in one payment, now"; and on a FIXED_MATURITY pool before maturity the contract refuses every request outright. Three sentences behind one word, and twelve places said the wrong one.

The decision, in two halves.

① The answer is DERIVED from the axes, never stored. No column, no field, no cached mode. The axes (redemption_type, epoch_duration_days, the maturity date against now) already exist and the chain enforces them; a stored copy is a second answer that disagrees with its inputs the first time one moves. Same reasoning that kept accrual_mode from becoming a column — a pool's mode is which implementation it was cloned from, and PoolCreated carries that address.

② The judgement takes EVERY axis, and the type says so. This is the half that had actually failed. The investor cycle banner read epoch_duration_days alone, so on a pool that repays in cycles after maturity it announced "Requests are open now. This cycle closes Sep 17." — to a signed-out visitor, about a pool whose requestRedemption reverts on every call until it matures. The dates were real; the sentence around them was not. A judgement built from a subset of the axes is how that happens, and passing them one at a time is what allows it.

🔴 The maturity gate outranks the cadence. A FIXED_MATURITY pool with a cadence has both, and reading the cadence first tells a holder to watch for a window that cannot accept them. PlatformPool refuses before it looks at any clock.

🔴 A FIXED_MATURITY pool with no maturity date is closed, not open. requestRedemption reverts on maturityDate == 0 || block.timestamp < maturityDate — the missing date is the FIRST half of that condition, not an exemption from it. The shape is not hypothetical: setMaturityDate is simply not sent when the value is absent, which is why pool creation now refuses a FIXED_TERM pool with no offering close (v3-145); pools that predate that guard are deployed. Two client derivations disagreed about exactly this case, one of them describing such a pool as "settles by cycle".

What this does NOT decide. Nothing about which combinations are allowed — that is 07-redemption and unchanged. Nothing about the lock-up anchor (v3-144) or the term anchor (v3-145), both of which this reads and neither of which it moves.

Refs: consumes v3-132 (post-maturity epoch pools) · v3-144 / v3-145 (the two anchors it reads) · combination table in 07-redemption · apps/web/app/shared/lib/redemption-state.ts

v3-147 — After maturity, the coupon rides the repayment cycle 🔨 Built · deploy pending

v3-147 — A matured repayment pool's yield date is its cycle's date, and the contract's promise layer gets its first writer

Date: 2026-08-25 · Status: 🔨 built, deploy pending. Off-chain only in terms of contract code — the on-chain half is a setter that has existed since the 2026-08-21 round and had zero callers.

The operational agreement (JY, 2026-08-24). On a pool that repays over several cycles after maturity, the yield payout and the principal repayment land on the same day. Two transactions, because the chain has two functions for them (depositYield and fundRedemption) and no third that does both — but one date.

🔴 Two ladders cannot stay on one date when only one of them is fixed. A cycle's date is decided up front and does not move when funding is late. A yield date was derived from performance: next due = the last paid period's end plus one interval. Pay once on the 20th instead of the 10th and every later coupon moves to the 20th while the cycles stay on the 10th. The agreement breaks on the first late payment and stays broken.

The decision. Before maturity, nothing changes — there is no cycle ladder to attach to, and the cadence is right. After maturity, yield period n is due on cycle n's payout date, stored rather than derived.

Where it comes from: nowhere new. redemption_epochs.funding_date already holds the date each cycle was given, and its own comment forbids anything else being written there ("never a planned or recomputed date: the plan lives on pools, the facts live here"). After maturity the coupon date is that date, so the answer was to read the column, not to add one. No migration.

⚠️ NULL in that column is load-bearing. It means the chain was never given the cycle's date and is deriving it as previous + epochDurationDays — three days early in the first month of a monthly plan and eight by the fourth. A derived date must never be promoted into a promise, so unwritten cycles are left out of the list rather than read from it. The gap is what the funding-date reminder (v3-135) exists to chase.

How the next date is picked: the earliest cycle date after the anchor. Not "cycle number n" — that phrasing needs a cursor, a count of the pre-maturity periods, and a map from a cycle index onto a yield-period index, and all three shift the first time a period is missed or settled late. "After the anchor" needs none of them, and a cycle whose coupon went unpaid keeps its own date and reads as overdue, exactly as the rest of the schedule does.

🔴 Order: the MATURED cap runs first, the plan picks up only what the cap dropped. A period that came due while the term was still running is a pre-maturity obligation and is late. Handing it to the plan would move it forward onto a cycle date and quietly un-late it. The cap (v3-124's neighbour, in resolveNextYieldDue) keeps that period exactly where it was and the plan takes over at the point the cap used to end the schedule.

The on-chain half — a setter with no callers. The 2026-08-21 round (v3-143 ③) gave the contract two layers: a stored yieldDueDate[n] an operator writes, and a linear yieldPeriodDays forecast for periods nobody confirmed. The stored layer is the reason a promise can be a calendar date at all. Nothing off-chain ever called setYieldDueDate, so only the forecast was ever in play and the pool published a date the product had not promised. Worse, settleYield advances the layer itself (YieldLib.sol:183-187), so after every settlement the chain holds a 28-day forecast under a stored date's name — a sync has to overwrite it, not fill an empty slot.

🔴 Which period to write is read from the chain, never derived. yieldDueDate's index is a yield-period number counted from the anchor, not a cycle number: a pool that paid twelve monthly coupons before maturity has cycle 1 land on period 13. Deriving that offset requires the pre-maturity count to be exactly right, and it moves the first time a coupon is missed. The sync reads yieldPeriodsSettled and writes + 1 — the period the pool owes next, and the only one it may touch.

It hangs off the daily sweep, not off the confirmation endpoint. confirm_funding_date runs at deploy time, before maturity, when the offset is unknowable. The sweep already computes the plan's answer for exactly these pools, so it covers both the first write after maturity and any later drift, and it costs no transaction on a day when the date has not changed. That makes pools.scheduler.yield-due a signing function: ORACLE_ROLE policy plus its own execution role, since a signing function on the shared role would hand kms:Sign to every scheduler beside it.

Non-fatal by construction. yieldDueDate gates nothing on chain — grep it in apps/contract/src and it is read by the forecast and by nextYieldDueAt, and by nothing else. A failed sync leaves a stale announcement and touches no money, which is why it is the tail of a sweep rather than part of a settlement. InvalidSchedule on a date the pool has already reached is expected traffic, not a bug: a cycle funded after its own date has passed lands there, and the period is genuinely overdue.

🔴 A failed read is never passed on as "no dates". An empty plan resolves to "the schedule is finished", and that answer gets written — one failed query would clear next_yield_due on every repayment pool at once, and the due queue and the overdue escalation read only that column. The batch sweep aborts; the single-pool paths leave the stored date alone and let the next sweep recompute.

The two amounts, and where they had to come from. GET /pools/{id}/repayment-cycles now answers per cycle with a principal axis and a yield axis, each carrying its own status, the single figure a screen shows, and whether that figure is a projection. No combined total — it is false for as long as one side is outstanding, and one side arriving first is a state every cycle passes through, not an edge case. The backend decides both axes so the investor, FM and admin views cannot align a cycle three different ways.

🔴 redemption_epochs.total_demand_lp is a column no writer has ever filled (one definition, zero writes), so the principal figures have no home but the chain. Dates are still never read from it — a derived date and a given one are indistinguishable there, which is the whole point of the endpoint — but a balance has no such ambiguity, and the reads are made only for cycles that HAVE a given date.

🔴 epochFundTopUp means two different things either side of settlement. A settlement debits it first (RedemptionLib.sol:934-938), so before settlement it is what the partner put in and after it is what the demand did not consume. It is reported under two names — funded_usd and surplus_usd — because reading the second as the first calls a fully-repaid cycle unfunded.

And the same rule had to hold on the other side of the period. derivePeriod still walked the cadence, and period_end becomes the next anchor — so on a 28-day plan against a monthly cadence the anchor landed PAST the next cycle's date, and that cycle's coupon came due never. The two rules are one rule now, sharing nextPlannedPayout. Found with it: the grid's fallback start was start_date, the day the offering OPENED, so a pool's first coupon covered its own fundraising window ([v3-145] moved every other anchor and missed this one).

Screens: all three built (D-4 §3-3). The admin repayment card gains a two-row column; the FM's cycle block puts the two funding actions side by side in no order, because there is no gate between them on chain and forcing principal first would block a manager who has only the yield ready.

The investor sees their OWN amounts (JY, 2026-08-26) — and the two rows turn out not to be the same kind of statement.

🔴 Yield can be a per-holder fact; principal cannot be a per-cycle one for anybody. yield_distribution_investors records what the contract credited each holder, taken from claimableYield either side of the settlement rather than from a share ratio we computed. Principal has no per-cycle figure at all: one claimRedemption settles every cycle since the holder last claimed — its vintage epoch as new demand, then the cumulative carry ladder for the rest (_settleEpochClaim, RedemptionLib.sol:1131). RedemptionClaimed carrying no epoch id is not an omission; the settlement unit is the claim. Splitting it off-chain would mean re-deriving the two-tier ladder, its generations and its re-anchor — a second opinion on the contract's most intricate math, about money.

So the principal row states a state, and that is the more useful thing anyway. Post-maturity repayment runs through requestRedemption, so a holder who never made one is repaid nothing, indefinitely, and nothing tells them. NOT_REQUESTED carries the redemption action — on exactly one cycle, the earliest unsettled one with a date. Repeated down twelve rows it would read as twelve things to do, and offered against a cycle with no given date it would attach an action to a date the chain is merely deriving.

⚠️ A holder with no recorded figure for a cycle whose distribution exists reports PENDING with no amount. Falling back to the pool's total there would put somebody else's money on that holder's row.

Refs: extends v3-132 (the repayment plan) and v3-143 ③ (the promise layer); the cap it composes with is in resolveNextYieldDue; yield-interval.ts, yield-schedule.ts, contract/yield-due-date.ts, yield/sync-onchain-yield-due.ts, pools.scheduler.yield-due.ts. Source: JY (product), 2026-08-24.

v3-146 — The lock-up’s three dates are one create-time decision 🔨 Built · deploy pending

v3-146 — A lock-up needs an anchor, so one date freezes and one pool shape loses it

Date: 2026-08-25 · Status: 🔨 contract deployed (Base Sepolia, 2026-08-25 round), off-chain built, not liveFACTORY_ADDRESS_84532 still names the previous factory, so every pool created today is still per-investor.

What shipped on chain (CH). subscriptionEndDate now lives in RedemptionConfig beside lockupDays and maturityDate, isLockupActive counts from it and lost its investor argument, and PoolConfigLib.validateTerms judges the three as a set — at initialize and again in every setter that touches one of them. This is the contract half of v3-144; that card decided the anchor, this one records what the terms became.

🔴 It refuses rather than clamps, and that is the load-bearing choice. An earlier revision capped the unlock at maturityDate inside isLockupActive. That let a pool deploy and then enforce less lock-up than it advertised — silently, and none at all once the anchor reached maturity. The check moved to where the config is chosen, and the cap went with it. Two states are now unbuildable: a lock-up with no offering close (LockupRequiresSubscriptionEnd) and one ending after the term (LockupOutlastsTerm).

What it costs off-chain. Two rules, one fact — the close is no longer ours alone.

🔴 1. The offering close freezes once it anchors a lock-up. The admin form offered it as an editable scheduling knob, on a comment that said "the offering close reaches no contract". On a pool-wide pool the contract holds its own copy, nothing writes an edit back to it (setSubscriptionEndDate has no caller), and it refuses one anyway once the pool holds LP. So an edit after the deploy moves our half alone, permanently — and the direction that breaks is the reverting one: clearing the date makes lockupAnchorMs answer null, the backend reads "no lock-up" and accepts a redemption the contract then refuses. Refused now in the edit form and in PATCH /pools/{id}, from one rule in @aset/types so the tooltip and the 409 cannot drift.

⚠️ Scoped, and both halves earn their place. lockup_days == 0 leaves the date inert on chain, so shortening an offering stays a legitimate live edit; and lockupIsPoolWide already answers false for the null implementation a DRAFT carries, so this cannot lock the one moment the value can legitimately be set. It is the third of the three terms catching up to lockup_days and maturity_days, which were already create-only.

🔴 2. An open-ended pool carries no lock-up (JY). Its offering window is optional — a blank window is a real answer for a pool that never matures — so a pool-wide lock-up there has no day to count from. The alternatives were to demand a close from a pool whose shape is "no scheduled end", or to invent a second anchor nothing records. lockupApplies now reads the maturity model as well as the redemption type: two ways to lose the field, for two unrelated reasons.

The consequence, stated plainly: an open-ended pool has no exit gate at all. A penalty needs a maturity to be early OF (isBeforeMaturity is false with no maturityDate), so the lock-up was the only one. Every open-ended pool on dev already runs that way — lock-up 0, NO_EARLY — so nothing changes shape. A minimum holding period is deferred to the per-position round (JY, 2026-08-25) rather than built on a second anchor now.

Both refusals dissolve with their premise, and are written to. If maturity becomes per-position (v3-131 item 4) the lock-up follows it back to each holder's deposit: the close stops being an anchor, an open-ended pool can carry a minimum holding period again, and the reopen ban of v3-144 lifts with them. So the reason recorded in code is always "a pool-wide anchor", never "lock-ups do not mix with reopening / open-ended pools" — phrased the second way, the constraint outlives the premise that produced it.

What retires. lockupWithoutAnchorReason, which refused a lock-up with no close. It sat in the wrong branch and could never fire; the state it guarded is now unbuildable, and the two boundaries that enforce it (checkLockupWithinTerm before the deploy transaction, validateTerms on chain) are untouched. Deleted rather than repaired — a guard that cannot fire reads as protection and is not.

⚠️ A silent no-op found on the way. maturity_model was never in PATCH /pools/{id}'s field whitelist, and parseJsonBody takes any shape, so the edit wizard's maturity-model select saved nothing on a DRAFT and no error said so. It is a real field now, Class B like the other create-time terms, and its enum plus its pairing with maturity_days moved out of pools.post.create into validatePoolConfig where both writers reach them. Switching a draft to OPEN_ENDED sends maturity_days: null — an absent key means "unchanged", which is what left the old term standing behind the new model.

⚠️ None of this is live until the factory swaps. The new implementation is deployed and unused; pools.pool_implementation (v3-144) is what answers whether a given pool got it, and every pool on dev still answers "no".

Refs: contract half of v3-144 · anchor arithmetic from v3-145 · PoolConfigLib.validateTerms, PoolCommonLib.isLockupActive, offeringCloseLockedByLockupReason/lockupApplies, pools.patch.update.ts, pool-field-validation.ts

v3-145 — The term is counted from the offering close, so nobody is invested for less than it 🔨 Built · deploy pending

v3-145 — `maturity_date = subscription_end_date + maturity_days`, and the W9 ceiling retires with it

Date: 2026-08-24 · Status: 🔨 built, deploy pending. Off-chain onlymaturityDate is an absolute timestamp the backend computes and sends through setMaturityDate, so there is no contract change and nothing for CH.

What was wrong. pools.worker.deploy computed maturityDate = nowSeconds + maturityDays, so a pool's term started when somebody pressed Deploy. Two consequences, both silent:

  • A draft's maturity slipped by however long it sat undeployed, while every other date on the pool stayed where the wizard put it. deploy-dates.ts existed to name that drift.
  • The term started before the pool had finished raising. An investor who came in on the last day of the offering was measured against a term that had already been running for the whole window, and was paid a full term's coupon for a shorter holding. 07-redemption carried it as a known defect whose decided fix (per-position maturity, v3-131 item 4) needed a contract change and was never built.

The decision. Measure the term from the offering close. Deposits are only taken before it, so no holder can be invested for less than the full term, and maturity becomes a date the wizard can show and the deploy reproduces exactly.

🔴 It fixes the late-depositor defect without making maturity per-position. Maturity did not become per-holder; it stopped starting early. That is worth stating because the per-position card is still open and its motivation is now gone.

What retires with it.

  • The W9 ceiling (offering close ≤ maturity − one yield period), maxOfferingDays, minEffectiveDays and the "late investors receive only N payments" warning. All four existed because the offering was carved out of the FRONT of the term; nothing is carved out now, so a longer offering moves maturity out. features/pool-form/offering.ts had predicted its own retirement by the other route and said to delete rather than port, which is what happened.
  • The late-investor lock-up guard (D-7, lockup > maturityDays − offeringDays). Both ends of the term now share an anchor, so the condition collapses to lockup > maturityDays, which the wizard has always enforced. ⚠️ Note the correction: v3-144 records an earlier claim that this guard could never retire because the arithmetic survives an anchor move. That was right about the lock-up moving alone and wrong once maturity moved with it — what retires a guard is both ends sharing an anchor.
  • The deploy-instant drift. deploy-dates.ts keeps the two checks that are genuinely about the instant (an offering close already in the past; how near it is) and has no moving date left to report.

🔴 A FIXED_TERM pool now needs an offering close. A blank one used to mean "raise until the term ends" (0204), which stops being coherent when the term is defined BY the close — it would read as "raise until a date decided by when I stop raising". Refused in three places, on purpose: the wizard's publish gate, POST /pools/{id}/lifecycle, and the deploy worker. The worker is not redundant — a retry deploy skips the publish validations, and setMaturityDate is skipped rather than failing when there is no value, which would leave maturityDate == 0, read by _validateRedemption as "never matures" and reverting every request on a FIXED_MATURITY pool for ever.

⚠️ Only new deploys. A pool already on-chain keeps the maturityDate it was given; nothing migrates. end_date is the wizard's estimate and the deploy does not rewrite it, so a pool created under the old rule still shows the start-anchored number in the Configuration tab. resolveMaturityAt prefers maturity_date, so behaviour is right and only that row is stale — DeployDateReview now says so by name.

Also changed. The timeline draws offering → term → repayment rather than term → repayment.

The yield grid moved too (JY, 2026-08-24). next_yield_due is seeded as subscription_end_date + interval, not start_date + interval, and yield-schedule.ts's last-resort anchor follows. This was left open for half a day as a product question and then decided, and it has its own reason rather than being a consequence of the term: with the grid on start_date, a pool raising for longer than one period owed a distribution while it was still raising — out of capital that had not finished arriving, to a holder set that was still changing.

🔴 It is not only a display estimate. pools.post.create passes the seeded date to the deploy as yieldAnchorDate and it becomes the contract's yieldAnchor (AccrualConfig), which has no setter — so the anchor a pool is created with is the one it keeps. start_date survives as the fallback for a pool with no close, which after this round means an open-ended one.

⚠️ The wizard's ticks flipped twice in one round, and the rule that came out of it is written where they are computed: the tick list mirrors whatever pools.post.create seeds, and nothing else. It was start-anchored (matching the backend), moved to the close to match the term — which made the wizard promise a schedule nothing produced — put back, and moved again once the seed itself moved.

🔴 The close now anchors a MONTH-STEPPING grid, and it is a derived date. computeNextYieldDue advances with addUtcMonths from the LAST due date, so a clamp is permanent: a close on Jan 31 pays Jan 31, Feb 28, Mar 28, Apr 28 and never returns to the 31st. The repayment roll day is restricted to 1–28 for exactly this, but the offering close is start_date + offering days — nobody types it, and nothing was checking it. The wizard now refuses a window whose close lands on the 29th–31st and names the two lengths that land on the 28th or the 1st. Scoped to cadences that step in months (WEEKLY and CUSTOM-days add a fixed number of days and cannot clamp, so refusing them would be a rule with no failure behind it).

⚠️ Post-maturity, the yield date and the repayment date are agreed to be the same day, and that is NOT yet buildable. The two ladders are scheduled differently — repayment dates are fixed from funding_anchor_date, yield dates are derived from the last payout — so they cannot stay on one date: paying late moves the yield grid and leaves the repayment ladder where it was. The contract already has the mechanism (setYieldDueDate, a stored date that overrides the linear forecast, shipped in v3-143 ③) and nothing off-chain calls it; there is also no per-period yield date column. Scoped and handed to product in Claude-Plan/Handoff/post-maturity-yield-redemption-same-date.md.

Column comments corrected in migration 0209 (comment-only). pools.start_date said the yield schedule counts from it and pools.subscription_end_date described itself as the offering close and nothing more. The close is now the origin of the pool's whole timeline: three separate rules (maturity, the pool-wide lock-up, the yield grid) count from it, so moving it moves all three. resolveMaturityAt's last-resort derivation reads subscription_end_date + maturity_days, which reverses a W9 rule that the resolver must never read that column — correct then, because reading it would have let an operator shorten a pool's term by shortening its window; correct now in the other direction, because that is the term.

🔴 Two things downstream were still projecting the term from TODAY, found on the edge-case pass and fixed with it. The repayment plan's floor (earliestAnchorMs, the W2-2 rule for cycle 1's funding date) and the bar's repayment span (repaymentSpanDays) both computed today + maturity_days. A fair estimate while maturity was fixed at the deploy instant; now early by the length of the offering. The span being short only mis-draws a segment, but the floor being early is the failing direction: the wizard accepts a funding date whose cycle-1 window opens before maturity, and the deploy worker's W2-4 then refuses the deploy for a form that validated. Both take the close now, through one exported derivation (offeringCloseOf) that all six call sites share — the block, the epoch block, the step, the publish gate, the payload builder and the preview — because a single one of them deriving it differently puts the wizard's plan on a different maturity from the one the deploy writes.

⚠️ An open-ended pool can still be missing the anchor, and it is the only shape that can. A FIXED_TERM pool cannot be published without a close, so its lock-up always has something to count from; an OPEN_ENDED pool keeps the lock-up field (with no maturity the lock-up is the only exit gate) and its offering window is optional. That combination would leave a pool-wide lock-up gating nothing while every screen advertised lockup_days — an investor walking out on day one of a pool sold with a 30-day lock-up. Refused in the wizard (lockupWithoutAnchorReason) rather than silently zeroed, because "no lock-up" and "a lock-up we cannot enforce" are different pools.

Refs: reverses the anchor in v3-141/0204's reading of a blank close · removes the motivation for v3-131 item 4 · 07-redemption · 04-pool-models · pools.worker.deploy.ts, pools.post.lifecycle.ts, lib/shared/business/pool-maturity.ts, features/pool-form/offering.ts, features/pool-form/deploy-dates.ts. Source: JY (product), 2026-08-24.

v3-144 — The lock-up counts from the offering close, and both anchors stay live because pools are clones 🔨 Off-chain built · contract pending

v3-144 — The lock-up anchor is a property of the pool implementation, and the unknown case falls to pool-wide

Date: 2026-08-21 · Status: 🔨 the off-chain half is built (@aset/types lockupAnchorMs / lockupIsPoolWide / reopenBlockedByLockupReason, the investor app's judgement, the create/edit wizards' copy, the POST /redemption-requests gate, the reopen refusal). The contract half is handed to CH and not deployed, so every pool today is still per-investor.

What was wrong. Maturity is pool-wide (s.maturityDate, set at deploy) and the lock-up was per holder (investedAt + lockupDays), so a per-holder boundary could outrun a pool-wide one: offering 20 days, lock-up 15, maturity 30 passes every guard and leaves a last-day investor locked five days past maturity, alone, at the moment the pool opens for everybody. Two exit gates run in sequence in RedemptionLib.requestRedemption — the FIXED_MATURITY check reverts before maturityDate, and only then is isLockupActive consulted — so on a maturity-only pool the lock-up does nothing before maturity and can only do harm after it.

The decision. Count the lock-up from the pool's offering close for everybody. Deposits are only taken during the offering, so nobody is invested before the anchor, and an early investor buys a longer hold rather than an earlier exit — which they can see when they invest.

🔴 Both anchors are live at once, permanently. A pool is a Clones proxy pinned to its implementation at creation and there is no upgrade path, so this is not a migration and there is no date after which one answer is right. Every pool that exists today keeps the per-investor rule for ever. lockupAnchorMs (@aset/types) is the only place that decides, because three parties judge the same lock-up — the investor app picks the button, redemption-requests.post.create accepts or refuses the request, the contract honours or reverts it — and a disagreement between them is a reverting transaction rather than a cosmetic difference.

The unknown case falls to pool-wide, and only one direction is safe. close + lockup ≥ investedAt + lockup always holds. Reading a per-investor pool as pool-wide shows a later unlock: conservative, and no transaction can fail. Reading a pool-wide pool as per-investor shows an earlier one, which offers a redemption the contract refuses — gas spent on a promise the UI made. So the list is of implementations that are per-investor, and an unrecorded one is pool-wide. A pool with no recorded implementation is per-investor, since it predates 0205 and therefore predates all of this. ⚠️ Any implementation deployed from here on that still counts from investedAt has to be appended; forgetting only over-states a lock-up.

⚠️ A pool-wide pool with no subscription_end_date has no anchor and the lock-up gates nothing — mirroring the contract, where an unset anchor is 0 and puts the whole lock-up in the past. Falling back to the deposit would refuse a redemption the contract allows.

🔴 A reopen is refused while lockup_days > 0 (D-2), and the reason is the anchor. Keeping the old close leaves a new investor with a lock-up that has already run; moving it to a new close re-locks holders who had already cleared theirs. Neither is a state the pool can be in. Written as a property of the anchor rather than as "reopen and lock-ups are incompatible", because if maturity ever becomes per-investor the lock-up follows it back to the deposit and the constraint dissolves — a holder joining on the reopen would be locked from their own entry, which PlatformPool already stamps.

The wizard guard is NOT retired by this, and an earlier revision of these notes said it was. The refusal is lockup > maturity − offering, and moving the anchor leaves that arithmetic untouched. What changes is who it is about: today the one late holder, afterwards every holder, locked past the pool's own maturity. Worse, not gone.

Found on the way. create-pool.ts was still calling poolImplementation(), renamed to poolImplementationFixed() in the 2026-08-21 round. The old name reverts rather than returning zero, the read is deliberately non-fatal, and canReopenOffering(null) reads a missing generation as too old — so every pool deployed after the rename was being told it cannot reopen, which is exactly backwards. FACTORY_ABI is cast through Abi, so nothing typechecked the name.

Refs: the anchor asymmetry is stated in 07-redemption → 3-State Lockup Model · the generation column is v3-142/0205 · packages/types/src/index.ts, apps/web/app/shared/lib/redemption-state.ts, apps/infra/lambda/redemption-requests.post.create.ts, apps/infra/lambda/pools.post.close.ts. Source: JY (product), 2026-08-21.

v3-143 — Escrowed LP keeps earning, and the accrual basis stopped being a set that already had somebody else's name on it ✅ Shipped · on chain 2026-08-21 · 0206–0208 · no pool created yet

v3-143 — The accrual basis is `totalLpSupply − poolHeldLp`, and the escrow entitlement is booked at its boundary

Date: 2026-08-20 (contract) / 2026-08-21 (recorded) · Status: ✅ shipped — FixedYieldEngine + YieldAccrualPolicy, migrations 0206 / 0207 / 0208, and the API surface below. ⚠️ New pools only. Implementation 0x7C21153E… + factory 0xE8453DAc… verified on Base Sepolia 2026-08-21, with the deployed bytecode reproducing this branch's source to the byte. But poolCounter() is 0 — nothing has been created from that factory, so no accrual has been driven on chain, and every pool that exists is pinned to an older implementation that still runs the old rule.

What changed. The accrual basis was totalSupply() − balanceOf(self) under v3-104 Option A and is now totalLpSupply − poolHeldLp — the recognised debt only. 🔴 The same expression, the opposite rule. Option A meant escrowed LP earns nothing; here the holder keeps earning and only the booking moves. This is v3-131 (3) landing, and it reverses v3-91 Rule 1.

How the escrow entitlement is recognised. It accrues per request against the epoch ladder — epochGy, the twin of epochG / epochH measured in accrual index rather than cash — and is recognised, debt and credit in one instant, when the escrow ends (FixedYieldEngine.recogniseCredit). Until then the difference between what the pool will owe and what it has booked is previewEscrowAccrual(requestId), per request.

🔴 Why the two halves have to be one set. The first attempt booked the debt the moment it arose and credited the holder only when the escrow ended. Fundings in between paid down a claim nobody held yet, and a later funding paid the same credit again — holders became able to withdraw ~2.8% more than the partner had sent. One obligation on two ledgers with two timestamps.

The boundary is settlement, not claim. previewEscrowAccrual reports everything earned so far, including the stretch since the last settlement, but a claim banks only up to that settlement — the rest is folded in by the next one. A cancellation or rejection banks all of it, because those price nothing. The path where an investor earns by delaying their own claim therefore stays closed.

The invariant that makes this safe to state. testFuzz_escrowNeverShrinksTheTotalBill (test/EscrowAccrualRecognition.t.sol): the total the partner ultimately owes is unchanged by escrow. outstandingYieldLiability merely lags it while an escrow is open, by exactly the sum of previewEscrowAccrual over the open requests. test_EscrowEarnsTheSameAsStayingIn pins escrowing and staying in to the same payout, within 4 wei.

⚠️ accrualBasisLp can read zero, and that is no longer the old failure. Every share escrowed at maturity is the ordinary case on a post-maturity epoch pool, not an edge case. Under the old rule zero meant the window's interest belonged to nobody — it accumulated in unclaimedYield with no holder it could ever be credited to. Now zero means nothing is recognised yet, and the window is still owed to the holders who are in it (test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders).

🔴 poolHeldLp is not settledUnclaimedLp. Two counters, two questions, and the three-denominator table under R10 had them confused — it printed the wind-down term where the accrual one belongs, which is the exact failure that table warns against one line above itself. settledUnclaimedLp is the wind-down denominator (R10) and the accrual basis never used it. Corrected 2026-08-21 in that table, in 23-money-path, in 05-investment-lifecycle — which had the hook "moves neither side of the accrual basis", true only of the wind-down term — and in v3-131's own text.

Off-chain. accumulatedYieldPerShare / yieldDebt are gone, so no per-share number exists to mirror and the backend asks the pool per read: 0206 retires the columns, 0207 adds pools.accrual_rate_bps (create-only — the contract has no setter), 0208 the exit-window anchor. POST /pools requires accrual_rate_bps, GET /portfolio-positions returns escrow_accrual, and the yield due row carries outstanding_net_liability.

Refs: implements v3-131 (3) · reverses v3-91 Rule 1 and re-reverses v3-104 Option A — both cards are left exactly as written, because they are the record of what was decided then · migrations 0206 / 0207 / 0208 · 26-glossary → 분모 · 17-changelog · src/libraries/FixedYieldEngine.sol, src/libraries/RedemptionLib.sol, src/policies/YieldAccrualPolicy.sol

v3-140 — AUTO turned off the only thing watching and handed the job to nobody, so the switch comes out rather than the reminder 🔨 Built · wizard + sweep + 0192 · deploy pending

v3-140 — `epoch_cycle_mode` stops being read

Date: 2026-08-18 · Status: built (wizard and pool-edit drop the field; the sweep drops the check; migration 0192 is comment-only). Not deployed, and 0192 is not applied. Closes the open seam recorded in v3-135 — though not by either route that card proposed.

The column offered a preference to a question that had stopped being one. SEMI_AUTO meant "remind an operator to confirm each cycle's payout date" and AUTO meant "do not". v3-135 had already redefined that reminder as the result of a state rather than a taste: the only question is whether this cycle's date has been set. Once that is the question, the column answers nothing.

🔴 AUTO was a switch that disabled the only watcher and put nothing in its place. It did not move the job of setting a funding date to anyone or anything — it silenced the ask. A pool on AUTO reaches each cycle with no date set, PoolCommonLib.epochFundingDateAt answers anyway with previous + epochDurationDays (looking back exactly one cycle), and settlement freezes that value into storage (RedemptionLib.sol:894). No revert. The wrong date crawls forward one cycle at a time.

And the on-request combination is the worse half, not the exempt one. v3-135 framed this as a post-maturity problem, and that framing was too narrow. A repayment plan at least has the deploy writing cycle 1 and attempting the rest (v3-133); an on-request epoch pool is the model where nothing is written ahead and the operator confirms each cycle as it comes — so AUTO there makes nobody has decided this date the default state of the pool rather than an exception in it.

How the seam actually closed. v3-135 offered two routes — stop the wizard sending AUTO, or move the plan branch above the mode check. Neither is what shipped, and neither would have been enough: the wizard sending nothing still leaves the 0104 NOT NULL default sitting in the column, and reordering the branches still leaves every on-request pool gated on the mode. The check is removed from the gating outright.

Runtime readers beforeOne — the hourly sweep. The other two schedulers only carried comments saying they do not gate on it
The sweep nowTwo branches, no mode check. Same question, different evidence: a plan pool is answered from the redemption_epochs.funding_date set difference and needs no clock; an on-request pool creates its row when the cycle arrives, so "cycle N+1 has no date" and "cycle N+1 does not exist yet" are indistinguishable there and it works from the calendar instead
CreateSends the column at all. The 0104 default fills NOT NULL. Writing a literal would have code re-assert a choice nobody made, and every existing row is SEMI_AUTO anyway
pool-editWas round-tripping the column through permissions.editable and the PATCH payload with no UI — removed with the rest

The column stays; only the reads go. Every row carries a value under a NOT NULL default, and dropping a column to express "we stopped caring" rewrites history for the pools created under the old reading. Retirement is its own deliberate slot. Until then the honest state is present, populated, unread, and 0192 says exactly that in the column comment — no DDL, no row touched.

🔴 Do not reintroduce a read. If a future feature wants "stop reminding me about this pool", that is suppression of a notification and belongs with the notification preferences — not in a schedule column whose value silently changes which dates a pool pays on.

Nothing inherits a behaviour change. Measured on dev: all 8 pools are SEMI_AUTO, none is a plan pool, so no pool has ever actually been on AUTO.

Refs: closes the seam in v3-135; the reminder's state gate is v3-135 and its date suggestion is v3-136; the derivation it protects against is v3-133; supersedes the epoch_cycle_mode scoping in v3-105 and the mode itself from v3-93/0104. 22-notifications → repayment-plan reminder gating · 11-db-schema → pools · 17-changelog v3-140. Migration 0192 (comment-only, pending dev apply). Source: JY (product), 2026-08-18.

v3-139 — The funding-date endpoint takes a cycle number, the pool row still speaks only for the accepting cycle, and the ordering nobody was checking is checked before the send 🔨 Built · endpoint only · deploy pending

v3-139 — Per-cycle funding-date confirmation, and a monotonicity guard in front of the setter

Date: 2026-08-17 · Status: built (POST /pools/{id}/epoch-schedule gains epoch_id and a pre-send order check). Not deployed — the API is a manual deploy. No contract change; see the caveat under the ordering section.

The endpoint could only ever name one cycle, and v3-133 needs it to name any of them. confirm_funding_date read acceptingEpochId and confirmed that cycle, which is exactly right for an on-request pool: there is one cycle in play and the operator is confirming it. A repayment plan is a finite list written at deploy, and what the confirmation card finishes is whatever the deploy could not reach — cycles months out from the cursor. So epoch_id is an optional field: omitted, the server resolves the accepting cycle as before, and the one existing caller does not pass it, so no path changes. A cycle behind the accepting one is refused with 409 rather than sent — the window is already open and the chain would revert WindowAlreadyOpen, and a 409 that says why beats a decoded revert.

🔴 The pool row is not a place to record a future cycle. pools.next_funding_date and the two provenance columns from v3-107 describe one cycle, the accepting one. Mirroring a cycle-4 write into them would have the pool announce cycle 4's date as its next funding date, set fundingDateConfirmed true for a cycle whose window has not opened, and hand the Epochs list, the investor badge and the reminder the same wrong cycle as a confirmed fact. The mirror is therefore written only when the target is the accepting cycle. A future cycle's record lives where it belongs: EpochFundingDateSetredemption_epochs.funding_date (0190) plus the activity log, neither of which claims to be "next".

That produced a second, smaller trap worth keeping. With the mirror conditional, the response's mirrored: false meant both "the mirror failed" and "there was nothing to mirror", and a caller distinguishing them by retrying would send a second transaction to a chain that already agreed. mirror_scope (accepting_cycle / future_cycle_no_pool_row) separates them.

Nothing checks that a cycle's date sits between its neighbours, and the pattern was already there. GovernanceLib.setEpochFundingDate checks a non-zero date, a non-zero cycle and the window guard — no ordering — while setEpochSettleAfter directly below it does compare against the funding date, so the omission reads as an oversight rather than a position. A date earlier than the previous cycle's puts that cycle's request window in the past, where _requireRequestWindow refuses every request and setEpochFundingDate refuses every correction, because a window in the past is open: a cycle that can be neither used nor repaired, created silently. The guard looks at both neighbours, since a date later than the next cycle's is the same defect from the other side. ⚠️ Refined afterwards: the two sides are not symmetric. A derived SUCCESSOR's date is epochFundingDate[thisCycle] + cadence — the slot being replaced, not a boundary — so it is compared only when it was actually set, and the same asymmetry is what the on-chain copy reads (previous derived, following from storage). The predecessor keeps its derived comparison, because nothing about that date depends on the write being made.

🔴 The guard is in the endpoint, not in the contract, and that bounds what it protects. checkFundingDateOrder runs in pools.post.epoch-schedule.ts before the transaction is sent; the contract is unchanged, so anything holding the ORACLE key and calling setEpochFundingDate directly still bypasses it. That is a defensible position for a guard against operator error and not one against a compromised key — and it is why this needed no deploy round and covers every pool including those already live, rather than new pools only. Moving it on-chain remains available and would invert that trade.

🔴 It compares against derived neighbours too, not only stored ones. A derived date is what the chain actually answers with, what investors see and what the next settlement freezes into storage, so validating against stored neighbours alone would pass a plan that genuinely overlaps. A neighbour that reads 0 is skipped rather than treated as a boundary — 0 means the derivation chain is broken, not that the neighbour sits at 1970, and reading it as a date would refuse every possible value. The message names whether the neighbour it collided with was set or deriving (the 0190 rows supply that half), because the fix differs: move this date, or write the neighbour first.

Refs: serves v3-133 and v3-134; extends v3-107's confirm endpoint and its provenance columns; 15-api-reference → Pools · 07-redemption; 17-changelog v3-133–v3-139. No migration and no contract change. Source: JY (product), 2026-08-17.

v3-138 — The key split left every new pool unable to sign, and the halt role is handed over before it is taken away 🔴 P0 fixed · deploy pending

v3-138 — Role grants follow the signer, not the deployer

Date: 2026-08-17 · Status: fixed in the deploy worker and pinned by tests. Not deployed. Pre-existing and unrelated to the repayment plan — found while adding the plan's own writes to the same worker.

Nobody had stepped on it because nobody had created a pool. Before the KMS key split (2026-08-14) the admin and oracle identities were the same key, so granting a role to the deploying account granted it to the signer by accident. The split made them distinct addresses and create-pool.ts kept granting ORACLE_ROLE to account.address — the admin signer. No pool has been created since, so the defect shipped and waited.

WhereWhat was wrong
create-pool.tsgrantRole(ORACLE_ROLE, account.address) named the admin signer, not the oracle one
handler-roles.ts9 call sites sign as oracle, and that address held no role
PlatformPool.initializePAUSER_ROLE went to _admin, the deploy key; the operational grant list never mentioned PAUSER at all

What a pool born in that state cannot do. Every oracle path reverts — NAV marks, redemption approve and reject, yield settlement, the epoch schedule, the tranche write-down — and so does every one of the 5 PAUSER-gated functions: pause, unpause, emergencyFreeze, unfreeze, tripCircuitBreaker.

⚠️ Close, wind-down and impairment were never broken, and the earlier version of this record said they were. setLifecycleStatus is DEFAULT_ADMIN_ROLE, which the deploy key holds. What did fail in those handlers is the clearOnChainPause leg they run alongside the escalation (v3-92), because that leg calls unpause. Recording a correct conclusion on a wrong premise misleads whoever reads it next, which is why the correction is here rather than a silent edit.

🔴 Grant the new holder before revoking the old one. The bootstrap PAUSER_ROLE on the deploy key is taken back — the comment above that grant in PlatformPool.initialize already described the hand-off as the intended end state, and now names the code that performs it — but the order is load-bearing: if the sequence dies half-way, what must survive is a pool somebody can still halt. Revoke-then-grant leaves the opposite. Pinned by a test that compares the two call indices rather than asserting the calls exist.

Refs: follows the KMS signer split (440f708); 09-rbac → the grant defect · 09-rbac → on-chain roles; 17-changelog v3-133–v3-139. Source: found in code review, 2026-08-16/17.

v3-137 — Funding left over in a cycle stays in that cycle, and the missing sweep is a contract decision nobody has made rather than a feature nobody has built ✅ Confirmed in code

v3-137 — A cycle's leftover top-up is bound to that cycle

Date: 2026-08-17 · Status: established against the contracts; the operating rule is on the admin funding views. No contract or schema change. Corrects v3-100 item 1, which closed v3-93's over-funding question with "leftover rolls forward to the next epoch" — that half is wrong, and it is the sentence every downstream copy of the error cites.

The question is what a fund manager does when they are unsure how much to send. The instinct is to send a little extra, and on this pool shape that instinct is wrong in a way no error message will ever tell them.

Three call sites decide it, and a fourth settles the argument. Funding accrues to epochFundTopUp[cycle] (PoolLedgerLib.sol:203-204); a settlement draws top-up first and reserve second (:246-250); and the amount a settlement may draw reads only the current cycle's bucket (RedemptionLib.sol:955). The decisive fact is negative: reserveBalance += appears exactly once in the whole repository (PoolLedgerLib.sol:73, the 10% split at deposit). There is no path that moves a top-up into reserve, and therefore no path that makes it available to any other cycle.

It is not lost, and saying so would be false as well as alarming. The leftover stays counted in totalEpochTopUp, which is the wind-down numerator (PlatformPool.sol:1438), so it is still pool assets and would be distributed to holders in a liquidation. The accurate claim is "bound to that cycle in normal operation", and it is also the one that changes behaviour: "gone" invites a support ticket, "bound" changes how much the partner sends.

🔴 The absence of a sweep is structural, not a gap. The bucket is keyed per cycle, so a sweep has to answer "into which cycle" — a policy question with no default, and answering it is a contract change. Copy that hedges with yet or not supported would report a decision nobody has taken as work nobody has finished.

No figure is shown, because none is reachable. readSettledEpoch().leftoverTopUp exists and reads the per-cycle remainder in the same batch as the fill ratio, but its only caller is the claim handler and no admin endpoint surfaces it. The screens therefore state the rule and no number; inventing one would be the CLAUDE.md rule 2-b defect class.

Fixed 2026-08-18 (copy + assertion + docs in one commit). The paragraph below is the record of what was wrong; see 22-notifications for what it says now.

🔴 A live notification said the opposite, and a test held it there. over_funding_detected fires to fundManagers after every settlement that leaves a surplus, and its body reads "The extra {excessAmount} {currency} carries over to the next epoch, so no action is needed" (catalog/redemption.ts:319-324, with a matching preheader and an "Excess carried" detail row). Both halves are wrong and the second is the expensive one: because the money is not available to the next cycle, the correct instruction is that the next cycle must be funded in full again — the exact opposite of "no action is needed". The comment above the entry reasons its way there by citing v3-93 C10, the same misreading that sat in epoch-redemption.ts until it was corrected, and catalog-integrity.test.ts:232 asserts /carries over/i, so the sentence is pinned by a test the way v3-96's fabricated "automatically" was. Copy fix made 2026-08-18 — the body now names the cycle holding the surplus and states that the next cycle cannot draw on it, the assertion was inverted to fail on returning carry-forward wording, and both moved with this page. Documenting the isolation without fixing the mail would have put the true statement in the docs and left the false one in the inbox.

Operating rule given to the partner: fund each cycle for what that cycle needs; what a settlement does not spend cannot be drawn on by a later cycle and cannot be withdrawn.

⚠️ Two different things "carry" in this engine and only one of them is real. Unfilled demand does roll into the next cycle (epochCarryDemandLp, served before new demand) and the investor-facing copy that says so is correct. Funding does not. Copy that borrows the first sentence for the second is how this defect appears.

Refs: stated in 07-redemption → constraints; wind-down numerator per R10 / v3-128; 17-changelog v3-133–v3-139. Source: code investigation, 2026-08-17.

v3-136 — A date a stored rule produces may be shown to a person and nothing else ✅ Decided · supersedes the v3-132 wording

v3-136 — What the two rule columns may be read for

Date: 2026-08-17 · Status: decided; the column comments and the three readers match it. Amends v3-132.

v3-132 said nothing may read epoch_date_basis and epoch_roll_day at runtime. That was aimed at the right hazard and drawn one step too wide: the confirmation card, the wizard preview and the reminder all have to read them, and all three are legitimate. The line is not reading — it is who acts on the answer.

A date produced from a stored rule may be shown to a person. It may not be written to the chain, and it may not decide a schedule.

AllowedRefused
The wizard's pre-deploy previewA scheduler deriving a date from the rule and writing it on-chain
The confirmation card's expected-vs-recorded diffA settlement path evaluating the rule to pick the next cycle
A reminder proposing a date for a human to confirmAnything that treats the produced date as agreed

Why the distinction holds. A derived date shown to a person is a suggestion they can reject; the same date written by a machine becomes the pool's real behaviour with no one having agreed to it, and on this pool shape a settlement then freezes it into storage permanently (v3-133). The failure mode this design exists to remove is not derivation, it is derivation with no human between it and the chain.

The column comments say it in those terms: "no runtime path may DERIVE A SCHEDULED DATE from these; a human-facing suggestion may read them."

Refs: amends v3-132; the reminder's proposal is v3-135; 11-db-schema → pools · 24-field-governance §5; 17-changelog v3-133–v3-139. Migration 0189. Source: JY (product), 2026-08-17.

v3-135 — The funding-date reminder is switched by the state of the plan, not by a setting the operator chose months earlier 🔨 Built · deploy pending · one seam open

v3-135 — Reminder gating is a consequence, not a preference

Date: 2026-08-17 · Status: built in pools.scheduler.epoch-funding-date. Not deployed. ⚠️ One unresolved seam below. Amends v3-132.

v3-132 turned the reminder off for this pool shape, on a premise that stopped being true. The premise was "every date is fixed up front, so there is nothing to confirm". v3-133 makes the deploy write those dates on a clock, so a plan can end up complete or truncated and nobody knows which from the pool row. Off was then the one setting that guaranteed nobody found out.

Any cycle still unwritten → remind. Every cycle written → silent. The gate is redemption_epochs.funding_date IS NULL (0190), not the epoch_cycle_mode toggle.

The distinction is between a preference and a consequence. An on-request epoch pool confirms one date per cycle for ever, so whether its operator wants a nudge is a standing choice and epoch_cycle_mode is the right home for it. A repayment plan has a finite list that is either finished or not, and that is a fact about the pool rather than a taste.

🔴 Until the confirmation card is deployed this reminder is the only recovery path. The deploy does not retry — deploy_status is already DEPLOYED when the writes run, so a redelivered message is turned away by the idempotency guard — and an unwritten cycle does not read as missing on-chain. Whoever later decides this is "just a notification" and gates it off removes the last thing standing between a truncated plan and a pool paying on dates its operator never approved. Recorded at the call site as well as here.

And the date the reminder names changed. It proposed previous + cadence, which on a monthly plan is the 12th when the partner wires on the 15th — telling the operator to confirm a date the plan never contained. It now walks the stored rule from the anchor, which is the human-facing suggestion v3-136 permits. No new notification event: the existing one was fixed.

The seam this card opened is closed by v3-140 (2026-08-18). As first committed, the sweep evaluated epoch_cycle_mode !== 'SEMI_AUTO' → skip before it reached the state gate while the wizard still shipped this combination as AUTO, so the state gate was unreachable on exactly the pools it was written for. ⚠️ Neither remedy proposed here is what shipped, and neither would have sufficed — the wizard sending nothing leaves the NOT NULL default in the column, and reordering the branches leaves every on-request pool still gated on the mode. The mode check was removed from the gating outright, and v3-140 also widens the diagnosis: AUTO disabled the only watcher without appointing a replacement in both combinations, and the on-request one is the worse of the two.

Refs: amends v3-132; depends on v3-133 and 0190; 22-notifications → repayment-plan reminder gating; 17-changelog v3-133–v3-139. Source: JY (product), 2026-08-17.

v3-134 — Two writers put the same list on-chain in opposite orders, and the reasons are not the same reason 🔨 Built · deploy pending

v3-134 — Deploy writes ascending, the confirmation card writes descending

Date: 2026-08-17 · Status: built (writeFundingDates takes the order from its caller). Not deployed. ⚠️ Only the deploy side has a caller today — the post-deploy confirmation card is not in place, and the admin view layer for this pool shape is still in progress. The descending rule is recorded now because it is a property of the contract's guard, not of the screen, and rediscovering it after the fact costs a cycle that can never be corrected.

Both callers write the same cycles to the same setter, one transaction each, and each has to state its own order — so the module takes order as an argument rather than choosing one.

Deploy: ascending (2 → 3 → … → N). The run can be cut short by the Lambda clock, so what is left unwritten has to be the cycles furthest out — the near ones open their request windows first and are therefore the ones a truncation must not touch. This is only safe because the deploy has already refused to proceed unless cycle 1's window opens after maturity (v3-132's re-validation), which puts every later cycle further out still. 🔴 Relax that check and this order stops being safe.

Confirmation card: descending (N → … → 3 → 2). Nothing is truncated there and the hazard is the opposite one. setEpochFundingDate is refused once the cycle's window has opened, and that window is computed from epochFundingDateAt, which returns 0 for a cycle whose predecessor is unwritten. A zero window reads as "not open yet", so the guard is asleep — and writing cycle K is what arms the guard on cycle K+1, by giving it a real predecessor to derive from. Ascending on a schedule that is no longer far out can therefore lock a cycle out on the strength of a date nobody chose. Descending leaves the guard asleep until the last write.

Why this is a decision and not a detail. The two orders look like an inconsistency, and the natural tidy-up is to pick one. Picking either breaks the other caller, silently, in a way that shows up as a cycle that can never be corrected. The test suites are deliberately split for the same reason: the contract suite pins the deploy's ascending case, and the card's tests say in as many words that "everything is descending" is not the rule.

Refs: implements the write side of v3-133; the endpoint that accepts a cycle id is v3-139; 07-redemption → how the dates reach the chain; 17-changelog v3-133–v3-139. Source: JY (product), 2026-08-17.

v3-133 — The deploy writes every cycle's date, because an unwritten cycle is not a blank on-chain but a confident wrong answer that hardens with each settlement 🔨 Built · deploy + indexer + 0190 · pending apply

v3-133 — The deploy writes all N funding dates

Date: 2026-08-17 · Status: built (deploy worker · indexer · migration 0190 · GET /pools/{id}/repayment-cycles). 0190 is not applied to dev and the API is a manual deploy. Supersedes v3-132's "the operator approves the list after deploy" as the mechanism.

The chain has no rule, only a derivation, and it looks back exactly one step. PoolCommonLib.epochFundingDateAt returns the stored value if a cycle has one and previous + epochDurationDays if it does not. So a cycle nobody wrote does not read as empty — it reads as a date, stated as confidently as a real one. On a calendar monthly plan on the 15th:

What the wizard had approved   Mar 15 · Apr 15 · May 15 · Jun 15
What the chain would produce   Mar 15 · Apr 12 · May 10 · Jun  7

Three days out by the second cycle and eight by the fourth, with no revert anywhere. And it does not stay recoverable: every settlement writes the next cycle's derived value into storage (RedemptionLib.sol:894), so each settlement permanently fixes one more wrong date and the error ratchets forward.

So the deploy writes them, at the only instant it can. Maturity becomes a real timestamp at deploy, which makes that the first moment the plan can be rebuilt against it and the last moment before the pool is live with an incomplete schedule. Cycle 1 is already on-chain as the anchor; cycles 2..N are walked forward from that anchor rather than re-derived from maturity, because re-deriving could pick a different first date and leave cycle 1 belonging to a different schedule than the rest.

🔴 The cap is the clock, not a transaction count. A count would have to be guessed from a per-transaction wall time nobody has measured, and that guess is wrong on the day the chain is slow — which is exactly the day it matters. The worker re-reads its remaining time before each write and stops while there is still room for one full receipt wait (300s budget, 60s reserve).

🔴 A partial run is not a failed deploy. The pool is on-chain and correctly configured; what is missing is dates for cycles whose windows open months out, and each can be written later, in any order, until its own window opens. Marking the pool DEPLOY_FAILED would be a false record of a live pool. What a partial run must not be is silent, which is what v3-135 is for.

Migration 0190 — redemption_epochs.funding_date, observed facts only. It is written from EpochFundingDateSet and from nothing else. Recording an intended date here would collapse the line 0189 drew: the plan lives on pools, what happened lives on redemption_epochs, and the whole value of the column is that NULL means nobody set this rather than no date exists. ⚠️ It counts from cycle 2: cycle 1 is written by setEpochSchedule, which emits EpochScheduleSet and never EpochFundingDateSet (GovernanceLib.sol:154,171), so it has no row by construction and counting from 1 would report every plan as one cycle short for ever.

The indexer was discarding the events this depends on. EpochFundingDateSet was dropped whole unless it named the accepting cycle — which is every cycle the deploy writes. Left as it was, the deploy would have succeeded and 0190 would have stayed empty, so the reminder and the confirmation card would both have reported a complete plan as unwritten.

GET /pools/{id}/repayment-cycles exists to expose that null. The epoch summary is one row per pool about the cycle the cursor is on; a plan needs all of its cycles at once. 🔴 It deliberately does not read the chain — a chain read returns a number for every cycle and erases the distinction the caller came for. It returns rows for 1..term whether or not the ledger has them (a cycle nobody recorded anything about is the case worth showing), drops indices beyond the term, keeps term: null distinct from an empty cycle list, and 500s on a ledger read failure rather than returning [] — an empty list reads as "no cycle has a date" and the card would offer to write all of them, one transaction each.

Refs: supersedes the mechanism in v3-132; write order is v3-134, gating is v3-135, the endpoint that finishes the list is v3-139; 07-redemption → how the dates reach the chain · 11-db-schema → redemption_epochs · 15-api-reference → Pools; 17-changelog v3-133–v3-139. Migration 0190. Source: JY (product), 2026-08-17.

v3-132 — The repayment plan is stored as a list of dates, and the rule that produced it is kept only so a human can produce the same list twice 🔨 Built · wizard + schema · amended by v3-133/135/136

v3-132 — Post-maturity repayment plan, stored

Date: 2026-08-16 · Status: built (admin wizard + migration 0189 + the deploy guard). Not applied and not deployed — the API is manual (deploy:dev:api), so a green push does not mean this is live.

⚠️ Amended on three points, one day later. Who writes the dates: v3-133 — the deploy writes all N and the post-deploy card confirms them, rather than the card writing them. What may read the two rule columns: v3-136 — a rule may produce a date for a person to look at; the ban is on a machine acting on it. Reminders: v3-135 — gated on whether any cycle is still unwritten, not on epoch_cycle_mode = AUTO. Everything else on this card stands.

What v3-131 left open. That decision opened the combination; it did not say who fixes the dates or when. Answered here: the list is fixed after deploy, against the real maturity, and every date is written on-chain individually. The wizard's list is a preview, labelled as one. ⚠️ Superseded in part by v3-133: the writes happen in the deploy worker at that instant, and the operator's post-deploy act is confirmation plus whatever the clock cut short.

Why after deploy. Maturity is now + maturity_days counted at the deploy instant, so a pool saved as a draft and deployed later matures later. Approving dates before deploy means approving dates that are not the ones written, and the schedule cannot be repaired afterwards: setEpochSchedule is create-only, and setEpochFundingDate refuses a cycle whose request window has opened. Approving after deploy makes the mismatch structurally impossible.

Three columns (0189), and the line they may not cross. redemption_term_epochs is the plan's length as a cycle count, because months only divide cycles evenly under the calendar basis: 4 × 28 days is 112 days, which is no whole number of months, so the wizard asks in months under CALENDAR and in cycles under FIXED_DAYS rather than inventing a conversion. epoch_date_basis and epoch_roll_day are the create-time answers, stored so the confirmation card can rebuild the same list against the real maturity instead of asking the same two questions a second time.

🔴 Nothing reads the two rule columns at runtime, and nothing may start to. The dates are the artifact; the rule is kept for a human to re-run. A scheduler that derived a cycle's date from these would restore exactly what this design removes — a derivation that silently produces a different date from the one the operator reviewed, with no failure signal.

Roll days stop at 28. February has no later day, and every rule that covers for the missing 29th–31st moves a payout the partner agreed to. Sticky end-of-month (once a payout lands on the last day, later ones stay on the last day) is the rule that handles it correctly, and it is deferred with the case rather than half-implemented: a plain clamp walks the schedule permanently earlier.

The deploy re-validates the anchor, and refuses. v3-131 noted the anchor must clear maturityDate + recall_lead_days + request_window_days and left enforcement open. The wizard's floor counts from today, which is an estimate; the binding check is in the deploy worker, where maturity becomes a real timestamp. If cycle 1's window would open before maturity the deploy fails with the corrected date in deploy_error, because a pool deployed in that state has a first cycle that refuses every request and cannot be fixed. Scoped to FIXED_MATURITY — an on-request pool takes requests before maturity, where an anchor inside the term is ordinary.

epoch_cycle_mode = AUTO for this combination. The SEMI_AUTO reminder reads pools.next_funding_date and a "previous + cadence" derivation of it, neither of which knows about dates confirmed up front, so on this pool it would name a date that is not the one on-chain. Wrong beats noisy. Verified against the live DB (2026-08-15): no deployed pool uses AUTO, so nothing inherits a behaviour change. ⚠️ Superseded by v3-135: the premise "every date is fixed up front" only holds if the write run always completes, and it is bounded by a clock. The reminder is now gated on unwritten cycles and its proposed date comes from the stored rule. The wizard still sets AUTO, which is the open seam recorded on that card.

The investor app reads the cadence AFTER the maturity gate now. Three surfaces asked "does this pool have a cycle?" before "can this holder exit at all?", which is the same question in the wrong order and only wrong for this pool shape: the browse card labelled it Epoch · 28d, and the portfolio filed the holding under "settles by cycle". The liquidity model gained a sixth state, AWAITING_MATURITY — out of lock-up, but the pool refuses every request until it matures. It is not LOCKED (that ends on the holder's own date, this one on the pool's) and not EPOCH (nothing settles yet). 🔴 The branch is scoped to FIXED_MATURITY: an on-request epoch pool does settle on its cycle before maturity, and both deployed epoch pools are on-request, so widening it would be a live regression. Pinned by tests in both directions. The 3-state model underneath (redemption-state.ts, shared through @aset/money-domain) is deliberately untouched — it does not know redemption_type, and teaching it would move every FIXED_MATURITY derivation at once.

W4, the maturity-to-cycle-N timeline, is dropped rather than deferred. The date table it would have drawn from already lists maturity, every payout and every request window, so a second rendering of the same array adds no fact; the gap between maturity and cycle 1, which was the reason to draw it, is now stated twice in words where the operator sets it. Drawing a pre-deploy list as a timeline would also lend an estimate the authority of a schedule, which is the thing W0=C exists to avoid. If a picture earns its place later, it belongs on the post-deploy confirmation card where the dates are real.

Refs: implements v3-131 items (1) and (2); 11-db-schema → pools for the three columns; 17-changelog v3-132. Migration 0189. Source: JY (product), 2026-08-15/16.

v3-131 — A pool that repays over several cycles after maturity is a combination the chain already allows, and a wizard guard is the only thing refusing it 🔧 Decided · not built · MVP = 2 of 5 items

v3-131 — Post-maturity epoch redemption

Date: 2026-08-15 · Status: decided, not built. No schema, contract, or UI change has landed. Two of the five items below are the MVP; one is undecided and two are explicitly out of scope.

The product. Redemption is refused until maturity, and from maturity the pool repays principal across several cycles instead of one lump sum, paying the coupon on what is still outstanding throughout. Written as settings: redemption_type = FIXED_MATURITY with epoch_duration_days > 0.

Nothing on-chain gains a value, and that is the point. This is combination ② of the segment × mode matrix — two independent settings that already exist, both already enforced by the contract. RedemptionLib refuses exits before maturityDate and the epoch engine settles on the cadence; neither knows or cares that the other is set. What blocks the combination is v3-88 D7, an admin-wizard guard that forces epoch_duration_days = 0 whenever redemption_type = FIXED_MATURITY, on the reasoning that a matured pool pays out once so a cadence is meaningless. That reasoning holds for every pool that existed when it was written and fails for this one.

The alternative — a third redemption_type value — was rejected on precedent, not on taste. redemptionConfig is set only in initialize, there is no setter, and PlatformPool is not upgradeable, so each pool is pinned to the implementation it was created with and a new enum value reaches new pools only. The precedent is the last third value: LIQUIDITY_WINDOWS shipped on-chain but the deploy worker never populated its window array, so every requestRedemption reverted. v3-88 D5 closed the dropdown but left the value injectable through the API, and four pools were deployed in that state — permanently unable to redeem, retired rather than relabelled, because rewriting the column would make the DB assert a capability the contract does not have (migration 0106).

The model is general, not the deal. Neither the four-month schedule nor the quarter-of-principal instalment that prompted this is stored anywhere. Schedule length is per pool, and the per-cycle amount is not configured at all — each cycle settles whatever the publisher funded for it, pro-rata, so uneven instalments already work with no setting and no code. The only thing genuinely missing from the schedule is its end: an anchor and a cadence exist, a termination does not, which is the same three-value decomposition ACTUS uses.

Five items, and the two that matter.

#ItemLayerStatus
1Wizard accepts the combination; schedule block relabelled "post-maturity redemption"FE/BE🔴 MVP
2redemption_window_epochs — the "cycle 2 of 4" counterFE/BE + schema✅ Built as redemption_term_epochs (0189) and required, not optional — see v3-132 / v3-133
3Yield accrual ends at settlement, not at requestContract✅ Shipped 2026-08-20 — new pools only
4Maturity anchored per investorContract⬜ Out of scope
5Holders who never request after the last cycleOperations⬜ Not a build

(1) is not a one-line guard. pool-create/payload.ts gates three things on redemption_type !== FIXED_MATURITY: the cadence, the whole anchored-schedule block (epoch_schedule_type · funding_anchor_date · request_window_days · recall_lead_days · epoch_cycle_mode), and redemption_gating_bps — and only the last should stay gated. The first two have to be lifted together, because the create endpoint requires an epoch pool's chain terms as a set and setEpochSchedule is create-only, so an unanchored pool could never be repaired. The anchor also has to clear maturityDate + recall_lead_days + request_window_days, or cycle 1's request window opens before maturity where every request reverts — how the wizard enforces that is still open.

(3) reverses v3-91 Rule 1 for the second time, and this time on a ground that pass did not weigh. Rule 1 excludes escrowed LP from the yield denominator, so a requester earns nothing between request and claim. The objection is not that the requester loses a little — it is that the publisher's coupon does not shrink, so the requester's share is handed to the holders who stayed. Against a partner agreement that says the coupon continues exactly as before maturity, the platform is quietly not honouring the term. On a multi-cycle schedule it recurs every cycle, and an investor who requests their whole position once forfeits the entire schedule's coupon.

Accrual ends at executeEpoch — the moment funds are committed. Not at claim, which would pay investors for delaying their own claim; not at request, which is the defect. An unfilled remainder keeps accruing, because that principal is still deployed and still earning. Three exclusion segments sit between request and claim and settling on executeEpoch closes all three, including the non-obvious one created by executeEpoch not burning LP. The two defences v3-91 relied on when it wrote Rule 1 are untouched and are what make the reversal safe: epoch pricing is forward (a request locks in no price) and cancellation is already impossible after the cutoff.

(3) shipped 2026-08-20 (contract; new pools only, as (3)'s own note says).

⚠️ The first attempt at it overpaid, and how it was caught is worth keeping. It booked the escrow accrual as debt the moment it arose but credited the holder only when the escrow ended, so fundings in between paid a claim nobody held and a later funding paid the credit again — holders became able to withdraw ~2.8% more than the partner had sent. The aggregate backing invariant could not say which bucket was wrong, so a sharper one was added (invariant_claimableNeverExceedsFundedMinusWithdrawn: holders can never withdraw more than funded-and-not-yet-withdrawn) and it localised it immediately. A second, quieter defect was in the assertion itself: it compared 18-decimal accrual figures against 6-decimal token holdings, so it broke by hundreds of millions of wei as a matter of course — loud enough to hide the real violation behind. The fix is to recognise the debt and the credit in the same instant (FixedYieldEngine.recogniseCredit), which makes the accrual basis totalLpSupply − poolHeldLp and parks the escrow entitlement per request until its boundary. Sealed by property test first (test/EscrowAccrualRecognition.t.sol, 60,000 cases), because this class of algebra had already been wrong four times in the round. The accrual denominator is now totalLpSupply − poolHeldLp — the RECOGNISED debt only, and not the set wind-down excludes (R10 uses settledUnclaimedLp, a different term for a different question). Escrowed LP leaves that basis the moment it is locked and its entitlement is parked per request until the escrow's boundary, so accrualBasisLp can still read zero — every share escrowed at maturity is precisely this deal — and zero now means nothing is recognised yet, not nothing is owed (test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders).

What made it O(1). executeEpoch fixes tier ratios and never materialises a per-holder fill (that is derived at claim), so "stop this holder at settlement" had nothing to hook onto — this is why the pass that scoped it called per-request yield accounting the variable that decides the effort. The epoch ladder therefore carries a third cumulative beside epochG (surviving fraction) and epochH (cash paid): epochGy, the same surviving fraction integrated against the accrual index, with generationCloseGy mirroring generationCloseH on a restart. A request's escrow-period yield is then the two spans a fill already splits into — flat on the full principal until its own epoch settles, then the ladder on the unfilled remainder — credited per request into creditAccrued when the escrow ends. No loop, no materialisation, and EpochRequest.yieldAccSnapshot is load-bearing again after a release write-only.

Asymmetry worth stating: segment ③ (settled → claim) earns nothing, because the payout is priced; a cancellation or rejection credits to now, because those price nothing. Without the second half, a request opened and cancelled inside one epoch would have fallen between two settlements and lost the window entirely.

Instant pools are covered too — a PENDING_RESERVE wait has no partial fills, so it is a flat span from lock to settlement. ⚠️ The investor-facing figure is split: pendingYield covers the position only; yield attached to an open request is previewEscrowAccrual(requestId), and a UI showing the first alone under-reports for anyone mid-redemption.

(3) and (4) ship together or (4) waits. Both are contract changes; splitting them buys a second deploy round and nothing else. Neither reaches an existing pool in any case — a pool is pinned to its implementation, so both changes apply to newly created pools whether they ship together or apart, and there is no migration to weigh. The platform is still pre-mainnet, so no live position is affected either way. (4) is nonetheless out of the MVP — the interim control is a raise window of about two weeks, which bounds a late depositor's overpaid coupon to the width of the window.

(2) is deferred by default. Operations knows the cycle count from the term sheet, so the field buys a display counter and an end-of-schedule signal rather than a mechanic. It is create-only, though, so it can be decided late but not retrofitted.

Rejected, each for a reason worth keeping.

RejectedWhy
New redemption_type valueOn-chain and set only in initialize; deployed pools are pinned to their implementation and would never honour it (migration 0106)
New lifecycle phaseSame; MATURED already carries the meaning
A coupon-basis settingOutstanding principal is the standard default and the only behaviour the distribution logic has — the setting would have one option
Business-day convention / holiday calendar / business centresOnly the publisher's recall touches a bank, and recall_lead_days covers it; claims and funding are on-chain and unbounded by banking hours. A holiday straddling a cutoff moves that cycle's funding date, which operators can already do
A weighted-average maturity anchorA pool with no early exit has no early-exit penalty, so there is no arbitrage to price; closing the raise early is the control
redemption_gating_bps on this combinationIt caps per-cycle settlement while the publisher is already funding to a schedule — a second cap can only break the schedule. Hidden for this combination

Holders who never request (5) is sequenced, not built. Notifications first, then a termination clause agreed with the publisher and written into the terms, then only as much code as the clause requires. Building the mechanism before the clause exists produces something with no authority to run. Not the wind-down path — its permissions look convenient and its meaning is inverted: investors would see their pool labelled as liquidating. Waiting is not punitive in the meantime, since an investor who has not requested is not escrowed and keeps earning.

Still open: the publisher must confirm the coupon basis, what "9% monthly" denotes precisely (annualisation, payment frequency, day count), whether principal and interest fall on one date, and what happens when a date or amount is missed. Before launch: per-cycle funding procedure, loss handling during the schedule, public copy for a contractual fixed rate, and whether reinvestment stays enabled — it lets capital re-enter a pool that is repaying, so this product should disable it.

Refs: 07-redemption → post-maturity epoch redemption and segment × mode; 04-pool-models → redemption_type · redemption_window_epochs · maturity_days; lifts the wizard guard in v3-88 D7 and reverses Rule 1 of v3-91; cadence semantics from v3-124; 17-changelog v3-131. Notion "풀 상환 모델 — 구간 × 모드 (기획서)" + "만기 기준을 풀 단위 → 투자자별로 전환". Source: JY (product), 2026-08-15.

v3-130 — The lock-up anchor is emitted, not inferred, and a reinvestment anchors exactly as a deposit does ✅ Decided

v3-130 — Superseding BD5's "reinvested LP is lockup-exempt"

Date: 2026-08-13

The defect. positions[investor].investedAt is contract state and was never emitted, so the ledger fold rebuilt it from the LP mint — the only log still carrying the pre-mint balance the contract tests. A Transfer cannot say which function minted it, and reinvest() mints an identical one. A holder who fully exited and then reinvested leftover yield was given a lock-up the chain does not have: redemption-requests.post.create answered 400 Redemption is locked until the lockup period ends for a redemption requestRedemption would have accepted. Nothing failed, because the projection's value and the chain's value are each individually plausible and the chain's is not observable off-chain.

Two changes, and the first is the one that matters.

1. The contract states the anchor. Deposited and Reinvested each carry investedAt, read back AFTER the branch that may install it, so the event says whether this deposit anchored or restated an existing one. The fold copies it and has no anchor rule left. This is the rule the codebase already applies to YieldDistributed.yieldPerShare: the contract emits what it booked, exactly so the projection is a read.

occurred_at was not enough on its own. It equals the anchor for a fresh entry, but a top-up must NOT re-anchor, and whether a given deposit anchored depends on the pre-mint balance — contract state at a moment the ledger does not record. What was missing was never the timestamp, it was the decision. Emitting the value rather than a flag also makes the projection self-healing: every deposit restates the anchor in force, so a rebuild from partial history converges instead of leaving invested_at null for ever.

2. reinvest() runs the same zero-balance branch deposit() does. BD5 (Manual Reinvest V1) had called reinvested LP "lockup-exempt". That rule has no object: the lock-up is ONE boolean per investor (PoolCommonLib.isLockupActive), never per LP or per deposit, so with a non-zero balance neither entry point re-anchors and "exempt" changes nothing. Its only reachable case was a re-entry at zero balance — which deposit() itself calls a returning investor who must be locked again. BD5's stated guard, that penalties deter gaming, does not cover that case either: the EARLY window is FIXED_TERM only, so an open-ended pool has no penalty to charge.

The contract already treated the two entry points as one on every other axis: identical nonReentrant whenNotPaused whenNotFrozen whenActive requiresKYC stack, the same creditPrincipal, and money-path parity by E1 / v3-85. The lock-up was the only asymmetry.

⚠️ Old pools throw rather than mis-count. Both events changed signature, so topic0 changed and existing ledger rows carry no investedAt. The fold refuses them: a null anchor reads as UNLOCKED, and falling back to it would release every currently locked holder. Pools created against the old implementation need this redeploy, not a second code path — the same position 0183 took for the hold-back kinds.

Docs corrected: 05 → Reinvest V1, 04 → allow_rollover, and 07 → 3-State Lockup Model, which now states the per-position anchor rule the docs had left to a contract comment.

⚠️ Partly superseded on POOL-WIDE pools (W9 / 0204). Change 1 stands everywhere: both events still carry investedAt, the fold still copies it, and it is still a read rather than an inference. What changes is what the anchor DECIDES, and only on an implementation that measures the lock-up from subscription_end_date — there the re-entry case this entry reasons about is not re-locked, and change 2's "runs the same zero-balance branch" carries no lock-up consequence. On a per-investor pool this entry is unchanged, and those pools do not go away (Clones). investedAt remains the emitted record of when a position started on both. See 07 → 3-State Lockup Model.

v3-129 — Type shrinks on phones, columns grow on monitors: a big screen earns more content, not bigger content ✅ Decided

v3-129 — The app's responsive ramp is not the marketing site's

Date: 2026-08-13

Context. The landing site has a documented breakpoint system and the apps were asked to adopt it. Two things came out of reading it. First, the landing has two systems, not one: widgets/landing/* (8 files) hand-writes a five-step ramp per element (text-[2rem] sm:text-[2.5rem] md:… lg:… xl:…), while the four newer pages from ch/product (widgets/{trust,investors,about,fund-managers}/*, 20 files) use clamp() tokens declared once in global.css. The table that was circulating documents the older one. Second, the apps' layout responsiveness was already largely present — shell switches, 35 responsive grids, table overflow, and web already had a four-step gutter ramp — while their type and density responsiveness was absent: the eight-token scale was fixed px at every width.

Decided: port the token mechanism, not the per-element ramp. Web has 84 component files and admin 118. A five-step ramp maintained by hand per element is affordable for eight landing sections and is not affordable here; a token is a one-file edit that both apps inherit. The landing's own drift from the first form to the second is the evidence.

Decided: display type is fluid and shrink-only. Each clamp's max is the fixed value the token already had, so nothing renders larger than before on any screen. The five display sizes ramp stat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48 over a 375→1280px viewport. label/data/read (12/14/16) stay fixed: that is a legibility floor, not a design choice.

Why shrink-only, when the landing grows. The landing's ramp exists to create drama — a card grows 200→600px, a heading 40→72px. An app's large screen should buy information, not scale: a holder on a 27" display wants more rows, not a 2× APY figure. So the growth budget goes to the column, and what was actually broken was the small end, where a 48px hero on a 375px phone ate the screen.

Decided: the monitor rung moves the two apps in opposite directions. One token, --container-monitor (105rem = 1680px). web was capped at 1280px and spent half a 2560px display on gutter → it widens. admin had no cap at all → it gains one, because its list routes are wide tables and a seven-column row stretched across ~2496px turns reading one row into an eye-trip. Framing "monitor support" as a single direction is the mistake this replaces.

Deferred, deliberately. Phone support for admin/FM. The lg: drawer stays as built and untouched; admin is treated as laptop-and-up for now, so its ramp only has to stop the shell from breaking at small widths rather than earn a phone layout.

⚠️ The browse grid takes no viewport breakpoint above lg, because the viewport is not its width. AppShell puts a 240px sidebar left of the routed content at lg+, and it collapses at runtime. Every viewport-keyed column step there erred the same way: xl:grid-cols-3 fired at 1280 while the column held 912px and dealt 288px cards; a 2xl:grid-cols-4 dealt 274px. Both were narrower than the 368px the previous column count already gave, so each "more columns" step was in fact making cards smaller. It is lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] now: as many 300px-or-wider tracks as fit. auto-fill and not auto-fit, since auto-fit collapses tracks a short list cannot fill and stretches two cards to ~760px. 300px is the knob and it is a real trade, not a free win: it costs a column between 1280 and 1315 (cards 288 → 444 there), and buys a fourth from 1640. Grids with a fixed item count stay on plain columns — portfolio's "Discover more" is .slice(0, 3), where a track it can never fill only narrows three cards.

⚠️ tailwind-merge deletes a custom size token inside a kit component, silently — and only in admin. tailwind-merge classifies by class name, not by CSS property: text-lead matches none of its known font-size patterns, so it is filed as a text-colour and the neighbouring text-neutral-700 wins a conflict that does not exist in CSS. twMerge('text-lead font-bold text-neutral-700') returns 'font-bold text-neutral-700'. The text-2xl it replaced was on the built-in scale and survived, so this conversion introduced the break: seven admin CardTitles fell back to inherited size, 24px rendering as 16px. Two DialogTitles in the same set were already carrying text-lead before this work and had been silently rendering at the kit's base 18px all along, so the same edit fixed those two rather than breaking them.

The repo already guards this in two of three places. packages/ds-editorial/src/cn.ts and apps/web/app/shared/lib/utils.ts both build cn with extendTailwindMerge, registering all eight tokens under font-size — the comment there describes exactly this failure. apps/admin-web has no cn of its own: its Card/Dialog/Button come from @shard-lab/finance-dashboard-kit, which bundles a plain twMerge and merges inside the package, where an admin-side extension would never be consulted. Fixed at the call site with text-(length:--text-lead) leading-tight, a form even plain tailwind-merge classifies as a size; it carries font-size only, so the line-height is spelled out.

Nothing else is exposed. Web's PoolStatusAlert.tsx:216-217 looks identical and is fine — extended cn. The shared patterns.tsx is fine for the same reason (measured: SectionEyebrow renders 12px in both apps). <Link> and other pass-through components never merge at all. Two pre-existing admin instances remain logged, not fixed: pool-controls.tsx:639 and nav-change-dialog.tsx:173 render DialogDescription at 14px instead of 16px.

⚠️ No gate can see this. tsc -b, both production builds and the copy guard were green throughout; the class is valid, compiles, and is present in the emitted CSS. It is removed at runtime. Even a DOM sweep for elements carrying a token misses it, because the class is gone from the element it was meant to size — a measured getComputedStyle against a probe element is what surfaced it.

⚠️ Fluid tokens are rem + vw, never bare vw. A bare viewport unit ignores the browser's text-zoom setting, so the rem term is what keeps zoom working. Same reason the landing's min-h-hero carries a rem floor.

Scope of the cleanup (L4). 111 large-type call sites moved onto the scale — Tailwind default rungs (text-xlstat, text-2xllead, text-3xldstat) and arbitrary pixel values (21/22/23/24/26/30/34/40px) onto the nearest rung, max deviation 2px. The ~1,023 small-text sites (text-xs/text-sm/text-[11px]) were left alone on purpose: 12–14px should not scale, so consolidating them buys nothing here. Six text-4xl sites also stayed — they size a decorative glyph (, , an emoji), not type. This is why "the apps use three competing type systems" is a smaller problem than it counts as.

⚠️ The token scale carries tighter line-height than the Tailwind rungs it replaced (1.05–1.1 vs 1.2–1.4). Correct for a single-line figure, cramped for a heading that wraps, so the 30 converted titles that had no explicit leading-* gained leading-tight.

Verified: tsc -b clean on both apps · both production builds green · copy-guard clean (177 + 240 files) · compiled CSS checked for the clamps, page-shell's five rules, the 2xl cap in both apps and 2xl:grid-cols-4.

Refs: 25-design-system → Breakpoints and the page column · packages/ds-editorial/src/tokens.css · apps/web/global.css · apps/admin-web/app/shared/ui/page-layout.tsx. Source: JY, 2026-08-13.

v3-128 — The hold-back comes out: a capability nobody pulls still costs every formula that carries it ✅ Decided · removed

v3-128 — Reversing v3-112: the lever and its bucket are deleted

Date: 2026-08-13

What is removed. The partner-remainder hold-back, in full: fundingRestricted, heldFundReleases and heldFundReleasesRaw in contract storage, setFundingRestricted, _releaseHeldFunds and its clamp, both FundReleases* events, the HELD_FROM_PARTNER / HOLDBACK_RELEASED ledger kinds and their fold rules, the money_pool_state.held_fund_releases column, the indexer writer, business/holdback-release.ts, and the holdback_release_deferred notification. deposit() now has one arm: the partner remainder always transfers to fund_wallet.

Why this reverses v3-112. That decision weighed "declining to build the product path" against "deleting the mechanism", found deletion irreversible and expensive, and stopped there. It priced the deletion correctly and the retention not at all.

What retention cost was not the lever — it was the bucket. heldFundReleases was a term in five money formulas: the wind-down NAV numerator, the epoch available-liquidity sum, the instant settle gate, the epoch fill debit order, and the claim drawdown. Each carried it in the contract, in the fold, in the off-chain mirrors, in both front ends and in these docs, for a value that was zero on every pool that has ever existed. Anyone reasoning about redemption liquidity had to read a term, establish it was dormant, and set it aside — the second review in a row spent time doing exactly that. v3-112 itself observed that the clamp had an unresolved obligation-counting question and routed it to the contract owner; that question dies with the code rather than waiting.

What made it safe to remove now. Verified before deleting: fundingRestricted false and heldFundReleases zero on all seven deployed pools, zero HELD_FROM_PARTNER / HOLDBACK_RELEASED rows in the ledger, no projection with a non-zero bucket, and setFundingRestrictedOnChain with no caller anywhere. The removal changes no number on any pool.

⚠️ Pools are EIP-1167 clones and the implementation is pinned at creation. A pool created against the old implementation keeps the lever and can still emit its events — and the fold no longer has a rule for them, so it throws rather than mis-counting. That is the intended failure: such a pool needs this reverted, not patched around. The contract is redeployed without the lever; do not create pools against the old factory afterwards.

Two labels survive the code. Dormant lever stays in 26-glossary with no live instance, because the situation recurs and a formula that silently implies a dead term is running is the thing worth naming. And holdback_release_deferred is where the automatically correction came from — copy promising a mechanism that did not exist, with a unit test asserting the false word — which is why 22-notifications keeps the story after deleting the entry.

Not removed: the enum labels HELD_FROM_PARTNER / HOLDBACK_RELEASED. Postgres cannot drop an enum value without rebuilding the type and re-casting the column, and four views read money_events.kind — one of them rewritten days earlier in v3-126. Copying four view definitions into a migration to delete two labels trades a real risk for none. money_events_no_holdback_kinds makes them unwritable, and events.test.ts declares them in its existing RETIRED list, which is the mechanism this codebase already had for exactly this.

Refs: migration 0183 · 23-money-path §6 · 07-redemption · 26-glossary → Retired terms

v3-127 — The SBT is the identity source of truth, so a mint may not invent half of it and a login may not collapse the other ✅ Decided · fixed

v3-127 — An on-chain credential is only as trustworthy as the weakest translation at its edges

Date: 2026-08-12

Two defects in the KYC/SBT edges, one cause. The SBT is the identity source of truth (v3-19) and the on-chain deposit and exit gates read it directly. Both defects came from a lossy translation at the boundary — one wrote a value nobody had verified, the other read a value that had lost the distinctions it needed — and neither produced an error. Both produced a confident wrong answer that an on-chain gate then honoured.

(1) An unknown country was minted as KOR. mintKYCSBT declared countryCode?: string with countryCode = 'KOR' as its default, and the caller passed it conditionally (...(user.country ? { countryCode: user.country } : {})) — so a holder whose country mirror was empty got a credential asserting Korean jurisdiction.

That string is not descriptive. It is the input to both on-chain investor gates:

GateReads
Jurisdiction whitelistPlatformPool.sol:632jurisdictionAllowed[kycContract.jurisdictionHashOf(investor)]
US-personPlatformKYCSoulbound._isUsCountry(data.countryCode)

The off-chain gate fails closed on an unknown country (checkKycGatingUS_PERSON / JURISDICTION_BLOCKED), so the two sides disagreed in the dangerous direction: on-chain honoured the invented value, and an investor can call deposit() directly without passing the off-chain gate at all. A KOR-whitelisted pool would have accepted a holder of unknown jurisdiction, and _isUsCountry would have answered "not a US person" about someone nobody had located.

Two live paths reached a null mirror: a GREEN review whose applicant fetch failed (the country was simply not written), and any row set to APPROVED by an on-chain self-heal, which never had one.

Decided — the country is required, and an unestablished one blocks the mint. countryCode: string with no default, so omitting it is a compile error rather than a silent substitution. mintSbtForApprovedUser resolves it through resolveInvestorCountry — the same helper the deposit gate uses, so both sides read one country including the institution rule (company_country ?? country) — and when the mirror is empty it pulls the applicant from SumSub once and persists it. If it still cannot be established the mint fails closed (sbt_status = FAILED, sbt_error = 'Investor country could not be verified', retryable). Resolution runs before the re-certification burn: refusing after the burn would leave the holder with no credential at all.

Rejected: a sentinel country, or any default. Every candidate value is either a real jurisdiction (which some pool whitelists) or a fake one that the whitelist comparison silently treats as "not allowed anywhere" — a permanent, invisible deposit block rather than a stated failure. A credential should not exist unless what it asserts was verified.

(2) A login read a boolean where the state has four values. POST /auth/verify called checkValidKYCisValidKYC, which is kycStateOf(user) == VALID. EXPIRED and REVOKED therefore arrived as the same false as "never verified", and resolveSbtSync had one false branch: downgrade to NOT_MINTED / NOT_STARTED / GUEST.

  • A revoked holder was un-rejected by signing in. applyRedReview's AML branch records kyc_status = REJECTED and keeps the token; the login downgrade overwrote that with NOT_STARTED. isFinalRejected is keyed off a REJECTED status, so the terminal ban stopped applying and the holder could start a fresh verification. GET /kyc/status already had exactly this guard (self-heal.ts, the B2 exclusion); the login path never got it.
  • An expired holder was blocked from their own money. Expiry blocks new deposits and must never block an exit — canRedeem is VALID || EXPIRED and v3-28 forbids an indefinite trap. But the downgrade left role = GUEST and kyc_status = NOT_STARTED, and POST /yield-claims gates value-out on INVESTOR + APPROVED. The off-chain gate refused a withdrawal the contract explicitly allows.

Decided — reconcile against the full state, and never widen a compliance verdict. resolveSbtSync takes 'NONE' | 'VALID' | 'EXPIRED' | 'REVOKED' | null and writes only the columns it has an opinion about:

On-chainDB writeWhy
read failed (null)noneUnknown ≠ absent (the pre-existing A1 guard).
VALIDadvance a lagging row to APPROVED / MINTED / INVESTORNever on a REJECTED row: a queued revoke still reads VALID briefly.
EXPIREDnoneThe credential exists; renewal is driven by sbt_expires_at, not by erasing the row.
REVOKEDkyc_status = REJECTED onlyTightens a row that still claims APPROVED (a revoke can land out-of-band). Token stays MINTED.
NONEsbt_status = NOT_MINTED, role = GUEST (+ NOT_STARTED)Only when the row claims a token; a REJECTED verdict is preserved.

checkValidKYC was deleted rather than left available — a wrapper that collapses four states into two has no caller in this codebase that can afford it.

Rejected: keeping the boolean and special-casing EXPIRED at each value-out gate. The state loss is the defect; patching its consequences means repeating the carve-out at every present and future gate, and one omission traps an investor's funds again.

Found while verifying, fixed in the same pass.

  • "One mint in flight" lived in one branch of one endpoint. The 10-minute claim was checked only on POST /kyc/mint-sbt's force path, so the plain path could queue a second mint that burns the token the first just minted — and the check was read-then-write, which two concurrent callers both pass. It is now a conditional UPDATE inside enqueueSbtMint (atomic, so one caller wins), which every caller gets: both endpoint paths (409 on refusal), the GREEN review, and the reconcile sweep.
  • A FINAL rejection could be cleared by a later log row. kyc_logs.status_result carries two vocabularies — verdicts (GREEN/RED, the only rows that can hold a reject_type) and lifecycle events (IN_REVIEW/RESET/DEACTIVATED, which never do) — and isFinalRejected read the latest row of any kind. An applicantDeactivated after a RED/FINAL put a reject_type-less row on top, and the ban stopped applying. Reads now filter to verdicts (readLatestReviewLog).
  • .single() reports "no rows" as an error, so the mint could not tell a deleted user from a failed read and treated both as permanent. .maybeSingle() splits them: a read failure is retryable, absence is terminal.
  • sbt_expires_at outlived the token it described. A renewal reset the SBT mirror but kept the old expiry, so a renewal whose re-mint then failed left the /kyc/status fallback and the T-30 reverify sweep quoting a burned credential's window.
  • The KYC poll ran forever for visitors who had no KYC. The refetch predicate enumerated terminal states and had omitted NOT_SUBMITTED, so every signed-in visitor polled GET /kyc/status every 10s with nothing pending — each call a users read, a kyc_logs read, a wallet lookup and an on-chain kycStateOf. The predicate now enumerates the waits instead (a state left off stops by default), the chain is read only when the row records a minted token, and the reject lookup runs only for a REJECTED holder.

⚠️ Existing data is not fixed by the code. Any SBT already minted while the country mirror was empty carries KOR on chain. Those holders need a force re-mint (burn + re-issue) once identified; no migration can correct an on-chain attribute.

Refs: v3-19 (three-state SBT, risk-tiered validity), v3-28 (no indefinite trap), v3-10 (jurisdiction + US-person gating), 03-kyc-identity → Login-time reconcile, 10-status-machines. Source: KYC flow review (2026-08-12).

v3-126 — A dropped table's defaults outlived it: the redemption view had PENDING_RESERVE inverted and no NULL-safe way to ask "on hold" ✅ Decided · fixed

v3-126 — Status is derived from events, so a view that reads the wrong event states the wrong thing confidently

Date: 2026-08-12

Two defects in money_redemption_list, one cause. The money-flow refactor replaced redemption_requests with money_events + money_redemption_workflow, and the view carried forward two assumptions that were true of the old table and false of the new one. Neither produced an error; both produced a confident wrong answer, which is the failure mode a derived status has and a stored one does not.

(1) PENDING_RESERVE and "already funded" were swapped. The branch read:

sql
WHEN fund.id IS NOT NULL THEN 'PENDING_RESERVE'   -- fund = a REDEMPTION_FUNDED event

RedemptionFunded is emitted by fundRedemption() — the partner's payment — and on the instant path that call requires the request to already be PENDING_RESERVE on-chain (RedemptionLib.sol:566, else RedemptionNotPendingReserve). So the event marks the moment the wait ends. fold.ts says the same in prose ("the partner's recall").

Meanwhile the state that genuinely is waiting has no ledger event at all: RedemptionPendingReserve is not in KIND_BY_CONTRACT_EVENT, and the indexer records it only as money_redemption_workflow.funding_shortfall. The view was joining that table already and not reading the one column that knew.

RealityView saidNow
Approved, reserve short, awaiting the partnerREQUESTEDPENDING_RESERVE
Partner funded, settlement not yet runPENDING_RESERVEPROCESSING

PROCESSING had been unreachable — absent from the CASE entirely — so deriveFundingStatus's status === 'PROCESSING' → 'FUNDED' branch was dead code.

⚠️ Branch order is load-bearing. funding_shortfall is written once and never cleared, so a funded request still carries a positive value; the fund branch must stay above the shortfall branch or every funded request reads PENDING_RESERVE forever.

Blast radius. Everything keyed on PENDING_RESERVE saw the complement of the set it wanted: the admin Redemptions funding hero, the confirmed-obligations timeline, the by-pool/by-fund breakdown and the Fund-all list all listed already-funded requests and omitted the ones actually awaiting money. GET /dashboard/stats's pending_reserve_amount filters the same status, so the platform's "awaiting funding" figure was summing the wrong rows — the shortfall formula was already correct (v3-122 moved it to reading the chain's stated value), but its input set was not.

(2) New is_held column, because exclude_held was dropping every ordinary request. redemption_requests.funding_status was DEFAULT 'FUNDED', which justified a bare funding_status <> 'HELD'. The successor column has no default and is nullable, and its only writers are explicit admin actions (approve hold/release, fund, record-funding) — a plain new request has NULL. NULL <> 'HELD' is NULL, which WHERE discards, so the epoch Open and Rollover queues returned nothing while the demand aggregate beside them (a separate SQL RPC, unaffected) kept reporting the LP. A total with no rows under it.

Fixed as COALESCE(funding_status = 'HELD', false) AS is_held in the view, not as a predicate in the query: the null-folding belongs once, beside the column whose nullability causes it. It is also deliberately not .or('funding_status.is.null,funding_status.neq.HELD') — correct on its own, but it emits a second or= param beside the endpoint's search filter, and how PostgREST combines repeated or is not something this query should depend on.

Rejected: restoring DEFAULT 'FUNDED'. 'FUNDED' was never an accurate label — it was the value a request carried before anyone funded it. NULL meaning "no hold in its history" is the honest state.

Two adjacent bugs found while verifying, fixed in the same pass. POST /redemption-requests/{id}/notify-fund sent payout_amount as the shortfall; that is the amount paid, null for a request that by definition has not been paid, so Number(null) = 0 shipped a real alert reading "[Action needed] Reserve shortfall … needs 0 USDC top-up". It now reads funding_shortfall and 409s rather than naming no figure. And readPoolHolders filtered lp_balance > 0, which is right for LP and wrong for yield: settleYield banks pending yield into accrued_yield on every balance change including the exit, and claimYield has no LP precondition — so a fully-exited holder can still be owed money. Both callers understated in the direction that hides an obligation (the receipt's unclaimed meter read low; the yield reconciler never compared those holders against the chain, its comment asserting "a position with no LP has no claimable yield").

Deploy order is not optional. The Lambdas filter on is_held; PostgREST returns 400 for a column the view does not have, so applying 0180 after the code turns an empty queue into an error. Migration first, then infra, then admin-web.

Refs: v3-122 (ledger fold, the four money tables), v3-96 (unverified copy), 07-redemption, 23-money-path. Migration 0180_redemption_list_status_and_hold_flag.sql. Source: JY (2026-08-12).

v3-125 — The portfolio prices principal, and principal is capped at par: the position delta is a writedown, not a return ✅ Decided · shipped

v3-125 — NAV is a principal price with a par ceiling, so the investor portfolio reports "principal change" and takes its return from yield

Date: 2026-08-12

The portfolio was built around a gain that the schema forbids. pools.nav_per_token carries CHECK (nav_per_token > 0 AND nav_per_token <= 1.000000), and nav_history.new_nav carries the same ceiling. NAV prices principal only: yield is distributed and claimed separately (MANUAL_CLAIM), so it never accrues into the token price. An ordinary deposit mints at 1.0, therefore tokens × NAV can only ever equal or fall below its cost basis, and the hero headline, the Value column and the row detail were all rendering a green +$0.00 (+0.0%) for the healthy case, under labels ("Unrealized") that read as investment return.

That is not a rounding nit. It tells an investor their return comes from price appreciation, when in this product the return is the yield strip and this figure is how much principal has been written down.

Decided — the figure is "Principal change", and flat is stated in words.

StateReadsTone
|Δ| < $0.005"At par, no change" / "Principal at par"neutral, no colour
Δ < 0−$1,200.00 (−4.0%)error
Δ > 0+$180.00 (+6.4%) + a hint naming the reasonsuccess

The positive branch is kept, not deleted: a gain is reachable by exactly one route, entering below par. entry_price is recorded for transfer-in and secondary holdings (21-holder-verification State C), so a holder who bought at $0.94 on a pool since priced back to par is legitimately up, and the row says so rather than leaving an impossible-looking number unexplained. Flat is compared at the precision actually rendered (2 dp) so a sub-cent difference is never painted as a loss.

Loss framing is unchanged from v3-109 / R8 and this decision does not reopen it: loss is NAV below par and nothing else. The reserve is shown as a size, described as redemption liquidity already inside the NAV the position is priced at, and never as absorbing, cushioning or being spent.

Two phantom fields, removed from the FE's mental model. redemption.nextSettlementAt and redemption.expectedFillPct are declared on PoolRow but no migration ever created either column and neither pools select reads one (grep next_settlement_at apps/infra → 0 hits). They are always null. The portfolio's redeem dialog was reading the first one, so every epoch pool silently fell back to "every N days" even when it had a funding date on record. The anchor an epoch pool actually has is next_funding_date, which LIST_SELECT does carry; fundingDateConfirmed needs two columns the list payload omits, so the portfolio badges the date expected, which is the safe direction.

Also corrected: the per-cycle redemption cap was disclosed to investors in raw bps ("up to 500 bps"); display is percent per v3-73. NAV display is unified on 2 decimals, the convention already used by position-card.tsx and computePoolRiskTier; the two 4-decimal call sites (portfolio redeem dialog, RedeemModal) were brought in line.

Guard, not a fix: an unreadable date no longer costs the route. Intl.DateTimeFormat.formatToParts() throws RangeError on an invalid date, and every formatter in shared/lib/formatters.ts is called straight from render, so one unparseable timestamp anywhere in a payload propagated to the root error boundary and replaced the whole app with "Something Went Wrong" — no shell, no page, nothing naming the bad field. zonedParts now detects it and each caller degrades to a dash. calendarDateInDisplayZone / shiftDays keep composing their key, because callers use the result as a YYYY-MM-DD key and only ever pass a date derived from todayInDisplayZone().

What was left out for want of data, rather than invented. Wallet idle balance (no balance hook, so the summary carries no "available to invest" split), a portfolio-level value-over-time chart (per-pool GET /pools/{id}/nav-history exists; no aggregate endpoint does, and summing NAV histories client-side would ignore when each holding was actually held), the interest / fee / writedown split of yield (claimable_yield is already net; the split would mean inventing two of three lines), and per-wallet holdings (money_positions is keyed by user, with no address column — replaced by an LP-state split: available / in redemption / locked / redeemable in full).

Result: /portfolio IA is summary → liquidity → positions → recent activity → discover. New: widgets/portfolio/{PortfolioComposition,LiquidityTimeline,PortfolioHistory}.tsx, shared/lib/portfolio-liquidity.ts, shared/copy/portfolio.ts (behaviour-claiming copy in one module, per v3-96). The /investor-activity row → ledger mapper moved to shared/lib/transaction-entries.ts so the Activity page and the portfolio's recent-activity section cannot drift on the payout-vs-LP amount rule. Composition colour is a validated single-hue ordinal ramp (brand-700/500/400/300): the DS has no categorical set that clears CVD separation, status hues would read as good and bad news on asset classes, and the previous 600/500/400 failed the adjacent-lightness check — which is why the segments were hard to tell apart. Four steps is the ceiling, so a fifth group folds into "Other" by size. /styleguide/portfolio renders the real widgets against fixtures, because the four position states (instant, epoch with an in-flight request, locked and written down, matured) cannot coexist on one account.

tsc -b clean, production build clean, copy guard clean, 26 tests passing. No BE change, no migration, no contract change.

Open: the row-count imbalance between zones (Yield carries 2 rows where Liquidity carries 9) is cosmetic and unresolved; empty states and the mobile layouts of the three new widgets have not been reviewed on a narrow viewport.

Refs: v3-109 (loss is NAV-vs-par), v3-96 (copy modules), v3-73 (bps stored, percent displayed), 21-holder-verification (State C entry_price), 07-redemption. Source: JY (product), 2026-08-12.

v3-124 — Three time bases stay apart: a cycle is "every N days", a month is 30, yield is the calendar ✅ Decided · shipped

v3-124 — "One month" is 30 days for durations, the epoch cycle is a day count and is never called a month, and yield stays on the calendar

Date: 2026-08-12

The same question kept getting re-litigated because three different mechanisms were all answering to the word "month". Is a 1-month lock-up 28 days or 30? The epoch cadence is 28, firstYieldIntervalDays is 30, and yield distribution is neither. Every attempt to reconcile them started from the assumption that one of the numbers must be wrong, when in fact each is anchored to a different mechanism and none of them is free to move.

Decided — name the three axes, and stop using one word for all of them. Full reconciliation is in 26-glossary → month; the operative rule is that 28 exists in exactly one place (the epoch cycle, as a day count) and every value called a "month" is 30.

AxisBaseSaid as
Epoch redemption cyclefixed 28 / 84 days"every 28 days" — never "monthly"
Yield distributioncalendar month, 12 a year (addUtcMonths, end-of-month clamped)"monthly"
Lock-up · maturity · duration display30 days (1 month = ×30)"N months"

No constant changed. This is a naming and display decision: the epoch cadence stays 28 / 84, yield stays on the calendar, the firstYieldIntervalDays floor stays 30 / 90, and epoch_schedule_type keeps its MONTHLY / QUARTERLY enum values. What changed is that the admin cadence selector now reads Every 28 days instead of Monthly (28 days), which is what frees "month" to mean one thing. Months are an input convenience for day-count fields, converted once at the boundary exactly like percent → bps (v3-80).

🔴 Do not lower firstYieldIntervalDays to 28. It is not a definition of "month" but the floor guaranteeing a pool's first yield distribution has happened before a lock-up lifts, so a YIELD_BASED penalty has something to take. It tracks the calendar axis, where the first distribution lands 30-31 days out. At 28 the lock-up ends two days early with accrued_yield still 0 and an early exit forfeits nothing, which is the hole v3-84 exists to close. Choosing 30 for the converter is what makes a "1 month" lock-up clear that floor exactly, with no warning shown.

🔴 Do not move the epoch cadence to 30 / 90. 30 mod 7 = 2 and 90 mod 7 = 6, so weekday stability is gone and windows drift onto weekends; 90 sits exactly on MAX_EPOCH_DURATION_DAYS; and setEpochSchedule is create-only, so pools already at 28 keep it and the estate splits. Full unification is unavailable regardless, because it would put yield on a fixed day count: 13 payouts a year on a drifting date, which month-end NAV and statement cycles cannot absorb. DPD is not a blocker either way, being counted in days already.

Also — the wizard now draws the whole cycle, because the leftover was invisible. The timeline stopped at the funding date, which read as though a cycle were window + lead and the next one began at the payout. It does not: the next window opens one full cadence later, so the monthly preset accepts requests on 7 of every 28 days and the payout is followed by an 11-day closed stretch. That leftover is cadence − window − lead, nobody enters it, and it is the direct consequence of the two numbers the operator does enter. See 07 → Model B.

Refs: 26-glossary → month (the reconciliation) · 07-redemption → Model B · 04-pool-models → yield_frequency · 17-changelog. Guards v3-84; does not alter v3-93 or v3-107. Source: JY (product), 2026-08-12.

v3-123 — The Yield review signs its whole priced set through the redemption runner, and counts deposits rather than settlements ✅ Decided · shipped

v3-123 — One runner for both money paths; a deposit whose settle fails is `done`, and the counter says so

Date: 2026-08-12

The Yield review priced every due pool and then ended at a permanently disabled button. Its tooltip explained that one operator cannot sign the whole set — which stopped being true on 2026-08-11, when v3-121's sibling A-2 shipped a runner that walks a list signing each item. The modal was already FM-only, so one fund wallet can sign all of its own pools; nothing had blocked it for a day except the note saying it was blocked.

Decided — extend use-fund-all-flow with a YIELD_DEPOSIT kind rather than write a second runner. Everything hard about a multi-item run is domain-free and already solved there: the loop (wagmi primitives taken once, because the single-pool flows bind their pool at hook level and N pools would need N hooks), the per-item state machine, the ref-based double-pay guard, per-item chain switching, and the signer check that turns a wallet mismatch into a sentence instead of a rejected transaction. Only two things differ — the pool function called (depositYield vs fundRedemption), and that recording yield is two server calls (POST /yield-distributions carrying deposit_tx_hash, then POST /{id}/distribute with retry-on-409) rather than one.

🔴 A zero amount fails here, where instant funding completes. Instant marks zero done deliberately: the reserve moved and re-sending would pay money nobody is owed. A yield row at zero is instead one the FM has not priced, and done is the state that suppresses retry — so completing it would lock that pool out of its own run. Rows left blank are dropped before the list is built; the runner's branch is the backstop.

🔴 A deposit whose settle fails is marked done, not failed. The deposit is irreversible, and failed is what invites a retry that would deposit the same yield twice. So the item completes carrying a message that names the real owner: this is the PROCESSING + deposit_tx_hash stall, recovery is ORACLE_ROLE-gated on Aset's key, and it needs ops rather than a reload. A batch run that looks successful can therefore leave stalled rows behind — see 13-operations.

🔴 The progress counter reads "deposited", not "distributed". Because done includes the item above, a header reading "2 of 4 distributed" sits directly above a row saying holders have not been paid, and contradicts the one line the operator most needs to believe. Deposited is true of every done item; the per-item message carries the rest. Understating settlement is recoverable, overstating it is not.

Also — the plan is snapshot when the run opens. Recording a distribution invalidates the due queue, so each success drops its pool from the live list: read live, rows would vanish from the stepper instead of completing, and a full run would end on an empty dialog.

⚠️ Shipped unproven on the money path. Compile, copy guard and all four render states are verified, and the Redemptions caller is unchanged (the dialog's three verb-bearing strings became props with the old wording as defaults). The signing path itself has not been run once — it needs an FM wallet holding a due pool's fund wallet, dev stablecoin and a due period. /preview/yield exists because that combination cannot be arranged on demand, and it is what caught this entry's counter defect. First real use should be one or two due periods, not a full queue.

Depends on: v3-120 (the lead window that fills this queue) · v3-121 (the FM/admin role split that made the modal FM-only) · Refs: 13-operations

v3-122 — Balances are folded from an append-only ledger; the four money tables are gone, legacy stays ✅ Decided · shipped

v3-122 — Money is a fold over `money_events`; the tables it replaced are dropped, and the pre-reset history is not

Date: 2026-08-10

Every balance used to be incremented by whoever happened to be looking. A deposit handler bumped pools.tvl, an indexer writer bumped it again on reconcile, a stored procedure bumped it a third time, and each of them was right in isolation. Nothing failed when they disagreed — the numbers stayed plausible, which is why the drift was found by a replay rather than by an alarm.

Decided — one append-only ledger, money_events, and projections that are pure folds over it. UNIQUE (chain_id, tx_hash, log_index) is the only idempotency key, replay order is (chain_id, block_number, log_index) and never id, and every row records the AXIS its amount is on (raw / normalized / LP / NAV) so the projection never has to infer a scale.

Phase 6 drops what it replaceddeposits, redemption_requests, yield_claims, redemption_fills, and seven RPCs that wrote balances. An empty table that still exists is the hazard: nothing stops a future INSERT, and a second writer that comes back looks like a plausible number, not an error.

The legacy schema stays, against the plan, because the plan's instruction assumed the ledger had replaced the history and measurement says otherwise: 17 of 35 deposits, 8 of 11 redemptions, 0 of 5 yield distributions, and no NAV at all (legacy.nav_history has no tx_hash column). Most of the gap is deliberate — soft-deleted pools excluded from the restore — but six rows on live pools are unrecoverable: a null chain_id, hand-made tx hashes, a retired chain. That absence is also why pools.nav_per_token remains the pricing source of truth.

What the migration found, none of which was failing loudly: an epoch claim path that was never dispatched, so a settled redemption still read QUEUED; accrued yield computed twice with different denominators, where the projection's zero won; a hard-delete guard asking three empty tables whether a pool had financial records; the admin pending queue reading 0 while two requests waited; and a maturity the investor app anchored to each holder's deposit rather than to the pool.

Depends on: v3-101 (amount axes as types) · Refs: 11-db-schema, 23-money-path, docs/refactor/money-flow-architecture.md

v3-121 — The Yield screen answers "who do I chase" and "who got paid", from records that already existed ✅ Decided · shipped

v3-121 — By-fund rollup and per-investor receipt, with the FM-notify state derived rather than stored

Date: 2026-08-07

The screen opened on a table, which says what happened and not what to do. Outstanding periods were listed per pool, so a fund four pools deep read as four unrelated problems, and whether its FM had been told was not on screen at all. Two endpoints close that, and both are reads of data the system already writes.

Decided — GET /yield-distributions/fund-summary, one row per fund with an outstanding period: owed (overdue only), pools behind, next due, and the fund's notify state. Built on the same fetchYieldDueRows the Pending queue reads. It could have been grouped in the browser — usePools() carries fundId and the due rows carry server estimates — and that is exactly why it is not: two independent sums of the same money drift, and the screen shows both (the exposure card and the By-Fund totals) side by side.

Decided — GET /yield-distributions/{id}/investors, the per-investor split behind a receipt. run-distribution.ts has always persisted user_id / share_percentage / amount per holder; nothing read it back. PII goes through the existing viewInvestor helper, so an FM sees name + wallet and never email (09-rbac) without a second copy of that policy.

🔴 The FM-notify state has no column, and the two fields that look like one are both wrong. yield_distributions.fm_notified_at is stamped by run-distribution.ts when a distribution runs, so it is null for precisely the unpaid periods the column is about. And POST /pools/{id}/escalate-yield writes no DB row at all — its own header says so. The durable record is the notification_events row the escalation leaves behind (event_key='yield_distribution_escalation', subject_type='pool', indexed on (subject_type, subject_id)).

And reading that table alone would have shipped a lie. dispatch.ts inserts the event first and resolves the audience second, so escalating a fund with no FM still leaves a "we notified them" trace for a message nobody received — the exact case the column exists to surface. The state is therefore derived from notification_events joined to notifications (the per-recipient inbox rows), giving three values that are not interchangeable: UNREACHABLE (no active FM, or delivered to nobody — assign an FM, re-notifying does nothing), NOTIFIED (delivered, waiting on the fund wallet), NONE (reachable, nothing sent). Collapsing the first into the second leaves an operator waiting on a request that never went out.

Rejected — adding pools.yield_escalated_at. It would be cheaper to query and it is the obvious shape, but it duplicates a fact notification_events already stores, and a second writer is a second thing to keep true. The join is one extra query on an indexed column.

Two dead columns confirmed dead, so nothing reads them. yield_distribution_investors.investor_name / .investor_address exist and no writer fills them (run-distribution.ts inserts user_id / share_percentage / amount only) — names come from the users join. yield_distribution_investors.claimed_at likewise has no writer anywhere (grep: 0 hits), which is why the receipt's claim meter is pool-level and cumulative rather than per round: what is actually tracked is portfolio_positions.accrued_yield, credited at settle and drawn down on claim / reinvest. Labelling that "unclaimed this round" would be a number nothing backs, so the heading names the pool.

Role split enforced on the screen, not just in the API. Admins and operators never hold a fund wallet, so depositYield is unsignable for them: the header Distribute button, the per-row Distribute action and the batch review are FM-only, and every admin path ends in Escalate to FM. The one exception stays as it was — recovering a stalled PROCESSING row via settleYield, which is Aset's ORACLE_ROLE. Terminology follows v3-96: "Record" is gone, one action is Distribute, and "signature" is never surfaced.

Refs: 15-api-reference · 09-rbac · v3-104 · v3-96 · v3-120. No schema change (no migration — both endpoints are reads) and no contract change. Source: JY (product), 2026-08-07.

v3-120 — A yield period becomes actionable a week before it is late, and says which it is ✅ Decided · shipped

v3-120 — The due queue gets a 7-day lead window, and an `is_overdue` flag so nothing silently over-counts

Date: 2026-08-07

The due queue was overdue-only, so the first place a period appeared was the day it was already late. due-rows.ts selected next_yield_due < now. Funding a distribution is not a same-day action — the FM has to have the money in the fund wallet and sign depositYield — so the queue's earliest signal arrived after the deadline it was warning about. The only earlier warning was the yield_distribution_due notification, which is an email, not a work queue.

Decided — YIELD_DUE_LEAD_DAYS = 7: the window becomes next_yield_due < now + 7d. Chosen to sit before the notification pipeline rather than overlap it. That pipeline waits YIELD_DUE_GRACE_DAYS (3) past due before escalating and re-sends every 7 days (pools.scheduler.yield-due.ts), i.e. it starts after the due date. A period is now visible for a week, goes overdue, and only then starts the escalation clock — the two never argue about the same day.

🔴 The dangerous part of this change is not the window, it is what the window breaks. The Yield screen's exposure figure and its "N pools overdue" count were sums over the whole due queue, which was correct only while the queue was overdue-only. Widening it silently folds not-yet-owed money into a number labelled overdue: no type error, no failing test, no visible defect — just a figure that is too big.

So every due row now carries is_overdue, and it is the only correct filter. overdue_days cannot substitute: it is Math.max(0, …), so an approaching period and one due today both report 0. The clamp is kept rather than allowed to go negative, because D+${overdue_days} was already in the CSV export. Everything meaning "money holders are already owed" filters on the flag — the exposure card, the By-Fund owed totals, the overdue counts — while the queue itself, the Pending tab and the FM's Upcoming timeline show the full window.

What stays true. missed_periods still floors at 1, so an approaching period estimates one cadence of gross rather than zero. The estimate is computed for approaching rows too, which is the point: the FM's reason to look early is to see the amount to fund. The CSV's D-day column now renders from getDDay, not from D+${overdue_days}, which would have exported "D+0" for everything not yet late.

Refs: 13-operations · 15-api-reference · v3-104 · v3-121. No schema change (the window is a query bound; is_overdue is computed in the read model, not stored) and no contract change. Source: JY (product), 2026-08-07.

v3-119 — A cycle is what gets funded, so a cycle is what the endpoint takes ✅ Decided · shipped

v3-119 — Pool-level epoch funding, and two KYC-gate corrections found while wiring it

Date: 2026-08-06

The money was already pool-level on-chain; only the API insisted otherwise. On an epoch pool Pool.fundRedemption(requestId, stablecoin, amount) never touches the request it is handed: the whole epoch branch credits epochFundTopUp[currentEpochId] and returns, using requestId for nothing but the emitted event (RedemptionLib.sol:444-459). But the only route to it was POST /redemption-requests/{id}/fund, so "fund this cycle" had to be hung off some queue row. The admin Epochs tab refused to do that and shipped the FM's one action disabled with a note pointing at the request rows — correct at the time, and the gap this closes.

Decided — POST /pools/{id}/epoch-funding, carrying the same two custody paths the per-request route has: amount (platform key signs — only viable where the fund wallet is that key, since fundRedemption is onlyRole(YIELD_DEPOSITOR_ROLE) granted to _fundWallet at init) or fund_tx_hash (the FM signed from the fund wallet; the endpoint verifies the receipt succeeded and touched this pool, rather than trusting a hash into the audit trail). requestId is sent as 0: there is no request, and nothing consumes RedemptionFunded off-chain — no indexer writer, no reader — so a borrowed id would only make the log claim a request was funded when none was.

Nothing is written to redemption_requests, deliberately. The credit belongs to the cycle. Stamping the cycle's N queued rows with one funding tx would misattribute it N times, and overwriting their tx_hash (which the per-request epoch path does for its single row) would replace each investor's own request hash with the partner's transfer. The off-chain record is a new POOL_EPOCH_FUNDED activity event; the on-chain record is the chain. The response reads the cycle back (readEpochShortfall) so the caller can tell "funded, still $X short" from "funded and covered" without a second round trip, and returns snapshot_unavailable: true rather than inventing numbers when that read fails.

Part-funding is a supported outcome, not an error. A short delivery fills requests pro-rata and carries the remainder, so the admin control defaults the amount to the shortfall and lets it be edited down. This is also why it could not reuse the instant funding flow, which derives its amount on-chain per request (netPayout + penalty − poolBalance) and has no equivalent before settlement under forward pricing.

🔴 The anchored-pool deploy failure was in gas estimation, not in the anchor. Deploying an epoch pool from the wizard died with setEpochSchedule reverting InvalidSchedule() (dev, "QA Epoch Verify 260806", args 2026-09-30 / 10 / 7, pool 0xC706DD59725…eeC993). The standing hypothesis was that the contract demands an anchor at least one full cycle out. It does not, and no such rule exists: the fourth guard, fundingAnchor <= (recallLeadDays + requestWindowDays) * 1 days (GovernanceLib.sol:211), compares an absolute unix timestamp against a duration in seconds — 17 days is ≈1.47e6, any real anchor is ≈1.79e9 — so it can only fire on a garbage value, and the wizard's own earliestAnchorMs is already stricter.

The guard that actually fired was recallLeadDays + requestWindowDays >= s.epochDurationDays reading 17 >= 0, because s.epochDurationDays is written by exactly one setter (GovernanceLib.sol:113) and the clone's initialize never touches it — despite epoch_duration_days also travelling in the DB row and in the RedemptionConfig the factory is handed. create-pool.ts did queue setEpochDurationDays ahead of setEpochSchedule, and its comment said the order was load-bearing, but order was only enforced for execution: every config call was gas-estimated up front, in parallel, against the un-configured clone. An estimate is a simulation and reverts exactly as the real call would, so the deploy died in pre-flight with no transaction sent — which is why the receipt trail pointed at setEpochSchedule and the anchor looked responsible.

Fixed by making the pre-flight follow the send order, not by reordering the sends. This was first written as a staged pre-flight (calls carry a stage; stages mine in sequence, calls within a stage keep pipelining), and that version did not survive contact with the second cause of the same failure, found independently the same day: the ops key carries an EIP-7702 delegation, so a node caps it at one pending transaction and any parallel batch is rejected at submission with -32000: in-flight transaction limit reached for delegated accounts — after gas has already been spent creating the clone. Staged pipelining still fires ~6 calls at once inside stage 0, so the cap breaks it too. createPoolOnChain is therefore fully serial: estimate, send, wait, next, with each call measured against the state its own send will execute in. ConfigCall.stage and planConfigStages were dropped as redundant; buildEpochConfigCalls stays, because array order now binds simulation as well as execution and the cadence→schedule dependency is still worth asserting without a chain. Estimation failures are re-thrown with the function name and the words "no tx sent", so the next dependency of this kind names itself instead of masquerading as a failed transaction, and the post-deploy read-back asserts epochDurationDays alongside fundingAnchor. See the in-flight-limit entry in 17-changelog for the delegation itself, which is the real fix and is still outstanding.

Not changed: lifecycle_status = ACTIVE alongside deploy_status = DEPLOY_FAILED. Reported as a state mismatch, it is the intended pair — lifecycle is the operator's publish intent, deploy_status is whether the chain caught up, and Retry Deploy requires the first to be preserved so the retry lands on the original target (pools.post.lifecycle.ts:62-78). Reverting to DRAFT would take the fresh-publish branch and lose that target. The combination is already contained on every surface: pool_address is written only on success so no on-chain path can act, investors are filtered on both API and client, and the admin chip renders Deploy failed ahead of the lifecycle chip.

Two corrections found while wiring the KYC-blocked case.

  1. The epoch claim does not revert RedemptionBlockedByKyc. It runs the same canRedeem check but raises PoolCommonLib.KYCRequired (RedemptionLib.sol:1049); RedemptionBlockedByKyc belongs to the instant settlement funnel (:544) and the permissionless fallback (:416). A front-end mapping added against the wrong name would compile, ship and silently never fire. The A9 cancel dialog does not depend on it — it gates proactively on the revoked soulbound — but the comment claiming otherwise is corrected.
  2. NONE was never documented at the exit gate. canRedeem is VALID || EXPIRED (PlatformKYCSoulbound.sol:371-373), so NONE is refused exactly like REVOKED; v3-19 named only the EXPIRED-passes / REVOKED-blocks pair, which read as though REVOKED were the only blocking state. Documented in 03-kyc-identity, with the per-path revert table.

Refs: 15-api-reference · 03-kyc-identity · v3-19 · 17-changelog. New activity event type POOL_EPOCH_FUNDED. No schema change (no migration; the credit is on-chain state) and no contract change — the endpoint calls the existing fundRedemption. Source: JY (product), 2026-08-06.

v3-118 — 04-pool-models gets an index and progressive disclosure; nothing is cut ✅ Decided · docs only · presentation

v3-118 — A 24-dimension reference should be skimmable before it is readable

Date: 2026-08-06

Decided — 04 opens with an index of every dimension, and deep mechanism folds behind <details>. v3-114 made 04 a concept spec by moving its archaeology out. What was left was still a reference wall: to learn which settings exist you scrolled seven category tables and ~60 sub-sections. So this pass is presentation only — no fact removed, no fact moved. One index table lists all 29 documented settings (25 configured, plus the four runtime / visibility flags) with a one-line "what it controls", its mutability and its status, each name linking to its own definition. Below it, six blocks of deep mechanism and three YAML examples fold behind <details>, which is disclosure-only: every line is still on the page and still in the DOM, so Ctrl-F and the site search reach it.

What folded, and why those: tranche grouping mechanics (how grouping works, same-group requirements, constraints, Junior depletion); the deposit flow's 8 numbered steps, which restate the diagram directly above them; the wind-down ASCII timeline, which restates the mermaid; the 60+30-day rationale; the risk-tier "receivables-first" rationale and its phasing table; and the three long YAML configs (33 / 36 / 40 lines). Example 3 stays open — its config is two lines, so a fold would cost more than it saves.

The anchor rule this pass had to respect. A native <details> does not reliably open on :target, so a deep link into a folded section lands on a closed box. Every heading and every explicit <a id> therefore stays outside the fold — only body content is wrapped. 04 carries 26 distinct inbound anchors from other pages (#emergency-wind-down alone has 13), plus two alias tags (#penalty-type, #mutability-matrix-summary); all of them verified present in the built HTML after the change.

One derived number was corrected as a side effect, and one stale diagram label. The seven category bullets carried per-category counts (Asset Configuration (7), Operations / Advanced (1)) that no longer matched their tables — five of the seven were wrong. Rather than re-typing them, the counts are now expressed by the index, which is generated from the tables themselves and cannot drift from them; the bullets are gone. Separately, the wind-down mermaid label still read NAV = distributable/totalSupply, contradicting the correct formula in the same section's prose thirty lines above it and the deployed contract (R10, see v3-116); it now reads distributable / claimingSupply.

Explicitly not redone: the flow sections (How Funds Flow, How Yield Works, How Redemption Works) were re-checked for material another page owns and every checkpoint already linked out — Reserve Split to 23 §3a, Fee Destination to v3-69 and the Mutability Matrix, the wind-down formula to 06 → R10. Nothing to link, so nothing was touched.

Refs: 04-pool-models · v3-114 (the trim this builds on) · 17-changelog. Spec Claude-Plan/Active/04-pool-models-scannability-spec.md (local, not in git). No schema, contract or product change. Source: JY (product), 2026-08-06.

v3-117 — Governance timelocks get an execute-due nudge, and a third-party execute now reaches the DB ✅ Decided · shipped

v3-117 — Nothing executes impairment or wind-down on its own, and until now nothing said so

Date: 2026-08-06

The premise, verified. A NAV decrease auto-applies: nav-changes.scheduler.apply-pending is a scheduled lambda calling applyPendingNavOnChain. Impairment and wind-down have no equivalent. executeImpairmentOnChain / executeWindDownOnChain have exactly one caller each — the action === 'execute' branch of their own POST handler — and both handlers are registered with HTTP paths:, never a schedule:. So the timelock expiring changes nothing by itself: a person has to act, and until they do the proposal sits with the pool in its pre-proposal state and nothing saying the decision was never enacted.

Decision 1 — nudge, do not automate. Two new opsTeam events, impairment_execute_due and winddown_execute_due, from an hourly sweep (pools.scheduler.governance-execute-due) at D-1 / D-3. Not fundManagers: executing is an operator action, and the FM's stake is the objection window, which the propose notice already covers. And explicitly not auto-execute — impairment is an irreversible status change and wind-down is terminal, so neither should happen because a cron woke up. The window has no upper bound (one failed run must not drop the only nudge); firing once is guaranteed by the idempotency key, which re-arms on cancel-then-repropose.

Decision 2 — executeWindDown() stays permissionless. It carries no role modifier (PlatformPool.sol:1330) while executeImpairment() is onlyRole(DEFAULT_ADMIN_ROLE). That asymmetry looked like a security bug and was filed as one; the docs specify it deliberately ("Timelock passes → anyone can call Pool.executeWindDown()"), the standard pattern where anyone may trigger an already-approved, timelocked action. Propose and cancel are admin-gated and the executor alters no parameter. Do not add a modifier — it would break the documented liveness design. Retracted as a concern.

Decision 3 — 🔴 the real gap was the mirror, and it is now closed. Permissionless execute is only safe if the execute is mirrored. The indexer watched neither ImpairmentExecuted nor WindDownExecuted nor LifecycleStatusChanged, and the only writer of lifecycle_status = WIND_DOWN was the BE execute branch — so an outsider could enact a wind-down on-chain while the DB still read ACTIVE and the UI kept offering deposits the contract reverts. That is the v3-92 class exactly, with a real trigger rather than a hypothetical one. All three events are watched now; the two *_executed writers reuse the handlers' own idempotency keys so the API path dedups instead of double-sending. is_paused is deliberately untouched (a third-party execute sends no unpause()), and the pool_updates feed entry is the acknowledged residual — createSystemPoolUpdate has no idempotency key, so the indexer calling it would duplicate the entry on the API path.

Also — one shared constant, and one shared NAV formatter. The 7 vs 30-day durations lived in four places with no link to the Solidity constants, which is how winddown_proposed_ops shipped "7-day" to the FM and understated their objection window by 23 days. Both handlers now import business/governance-timelock.ts, and the new *_execute_due pair renders the computed expiry rather than naming a duration at all. Separately, notifications/format-nav.ts is now the single NAV formatter: nav_change_proposed had been passing the raw NUMERIC column into '{oldNav} → {newNav}' and rendering 0.9166666666666666 → 0.85 to investors.

Open: ImpairmentProposed / WindDownProposed are still unwatched, so a proposal placed directly on-chain is invisible both to the propose notice and to the execute-due sweep (which reads the DB columns). The execute side is covered; the propose side is not.

Refs: 22-notifications · v3-92 · v3-97 · 17-changelog. Code (infra): lib/shared/business/governance-{timelock,execute-due}.ts · lambda/pools.scheduler.governance-execute-due.ts · lib/shared/notifications/{catalog/ops-variants,format-nav}.ts · lib/shared/indexer/{events,engine,writers/governance}.ts · lib/shared/contract/enums.ts · lib/stacks/api-stack.ts. No schema or contract change. Source: JY, 2026-08-06.

v3-116 — 07-redemption keeps the mechanics and drops the archaeology; three "not implemented" callouts were stale ✅ Decided · docs only · 🔴 four stale claims corrected

v3-116 — Trimming 07 to the present tense, and the gaps it turned out no longer had

Date: 2026-08-06

Decided — 07-redemption is the owner of redemption, so the mechanics stay. Unlike 04 and 05, 07 was not restating another page's material: the model selector, exit gates, the 3-state lockup, the four penalty types, both flow sequences, Model B's window arithmetic, the carry-first 2-tier fill and ladder generations, cancellation, the 3-layer partner funding and the anomaly-hold table all belong here and are untouched. What left is self-archaeology (ship dates, deploy addresses, selectors, commit hashes, "an earlier draft said…", "N tests passed"), internal duplication — eleven facts each stated 3–9 times, now one home apiece — and status badges wearing callout colours. 608 → 429 lines; call-outs 40 → 11, of which 8 are the numbered flow steps. :::warning / :::danger is now reserved for investor-funds risk (the failure path, the non-custodial claim guarantee, the two-bucket conflation).

One shared sentence replaced seven copies of the clone caveat. "Pools are Clones with the implementation fixed at creation, so this applies to new pools only" appeared verbatim beside every shipped contract item. It is now stated once under the page title and applies page-wide — deliberately kept, not deleted, because it is the current state of the deployed system and the reason the epoch FE still has nothing to build against.

🔴 Four stale claims, all verified against code and corrected. Three of them were the page's loudest content — full-width danger callouts announcing that Model B was designed but never switched on. They were true when written (2026-07-31) and were closed by 6c0a08f on 2026-08-03, which the page never caught up with:

  1. "The confirm action does not reach the chain yet." pools.post.epoch-schedule.ts calls setEpochFundingDateOnChain and is registered at POST /pools/{id}/epoch-schedule. The 확정 badge has a write path.
  2. "Neither provenance column exists yet." pools.next_funding_date_confirmed_at and next_funding_date_set_by shipped in migration 0111, and DETAIL_SELECT now carries them along with the other epoch-schedule columns — which also closes this card's sibling finding in v3-107 that GET /pools/{id} could not render a date, a badge or a countdown.
  3. "setEpochSchedule has no caller, so no pool has a window." The three schedule terms are required at create, sent through create-pool.ts, asserted back off the chain after deploy, and the contract reverts ScheduleNotConfigured instead of silently falling through to the lazy engine. No new pool can deploy unanchored; the legacy carve-out is now genuinely a carve-out. The indexer writers and ABI fragments for EpochScheduleSet / EpochFundingDateSet / EpochSettleAfterSet are in place too.
  4. The wind-down numerator was written with a subtraction the contract does not do. 07 gave distributable = reserve + recalled funds − settled-but-unclaimed debt; PlatformPool.sol:1370 reads reserveBalance + heldFundReleases + totalEpochTopUp, with no − redemptionCommitted. That subtraction shipped in v3-100 and was removed in 0abe168 because the three buckets are already net of it, so subtracting again removed the same dollars twice. The asymmetry that remains is the denominator one (R10), which is what the page now says. The same wrong formula was echoed in 07's C7 note ("redemptionCommitted … is subtracted from the wind-down numerator") and is gone with it.

One internal contradiction removed. The NAV-decrease carve-out was marked "✅ Shipped, deployed 2026-08-04" in the Payout Formula section and "🔴 not implemented" in flow step ② of the same page. Shipped is correct (RedemptionLib.sol via PoolCommonLib.effectiveNav); the flow step now links to the one statement instead of carrying its own.

✅ Two suspected fabrications were checked and are real — do not delete them. GET /redemption-requests/{id}/exit-gate is a live ADMIN-only endpoint (redemption-requests.get.exit-gate.ts, registered in api-stack), and the page's claim that POST /redemption-requests/{id}/fm-accept was dropped is accurate (migration 0050 drops fm_accepted_at; v3-34). Unlike v3-114 and v3-115, this page carried no invented API.

Owner map used: ship dates and deploy addresses → 17-changelog · "why this changed" → the relevant v3-NN (v3-91 / v3-93 / v3-100 / v3-105 / v3-107) · revert names, selectors and view signatures → 08a · DB columns and migrations → 11 · NAV mechanics → 06 · the money buckets → 23 · notification wording → 22 · full wind-down process → 04.

Still open, carried forward unchanged: the KYC-revoked epoch investor who can neither claim nor cancel while rejectRedemption refuses QUEUED / PARTIALLY_FILLED (P1, no party can close the request); whether an admin rejection carries the cutoff gate; the escheatment / unclaimed-property legal item; and the hold-back lever, which still has no caller in any lambda or admin screen and so remains unavailable in product.

Refs: 07-redemption · v3-114 · v3-115 (the same pass on 04 and 05) · v3-107 (the gaps closed in 2 and 3) · 17-changelog. Spec Claude-Plan/Active/07-redemption-readability-spec.md (local, not in git). No schema, contract or product change. Source: JY (product), 2026-08-06.

v3-115 — 05-investment-lifecycle is the investor's journey; the mechanics belong to their owners ✅ Decided · docs only · 🔴 one fabricated column named

v3-115 — What 05 is for, and the home for "there is no partner push"

Date: 2026-08-06

Decided — 05-investment-lifecycle answers one question: what happens to an investor, in order. Deposit → Yield → Redemption as a step-by-step walkthrough, each step one or two sentences of what happens, with the mechanism behind it linked rather than restated. The 29 step call-outs are the page's form and stay; what leaves is the second copy of formulas, fee arithmetic, penalty definitions and scheduler internals that another page already owns. 298 → 209 lines, no fact removed from the docs.

Owner map used (same shape as v3-114): deposit money mechanics → 23-money-path §3 · yield fee calculation and destinations → 04 → How Yield Works + 23 §4 · lockup states, the four penalty types and the reserve branch → 07-redemption · reconciler columns and schedulers → 11-db-schema · "why this changed" → the relevant v3-NN · deprecated columns → 04 → Migration Notes.

What 05 keeps as owner, so a later pass does not link these away too: the three-phase journey and its role badges; the Reinvest V1 Policy (effectiveNav pricing, partial, minimum, same-pool, the pending code fix); and Yield Settlement on LP Transfer, which has no better home — it is the holder's-eye view of onLpTransfer, worked example included.

This card is now the home for "there is no partner push on deposit." It had been recorded in 05 step ⑦ with 04 and v3-02 pointing at it, which made a walkthrough step the decision of record for three pages. The fact, unchanged: POST {partner_endpoint}/aset-lp-mint, its five-attempt retry schedule and the GET /lp-mints pull endpoint were published in the docs and never builtgrep across apps/infra, apps/web and apps/admin-web returns 0 hits for aset-lp-mint, lp-mints and partner_endpoint. What actually happens is that the fund manager reads the platform: GET /deposits is fund-scoped, and the deposit_confirmed_ops notification carries fundManagers in its audience. The push-to-partner concept is dropped, not pending — it is not to be described to a partner as available or as coming. Same defect class as the governance webhook in v3-114.

🔴 One fabricated column surfaced by the pass: portfolio_positions.nav_at_investment does not exist. 05 step ⑥ said it "records the NAV at investment time for accurate P&L tracking". grep returns 0 hits repo-wide, and the column is absent from 11-db-schema. What a deposit actually stores is effective_value = tokens × nav_per_token (the p_nav_per_token RPC argument), which is a current valuation and is overwritten on the next NAV move — so no per-deposit entry NAV is retained on this path at all. The nearby entry_price column is real but is written only by the indexer's LP-transfer writer when it first creates a position it has not seen, not by either deposit writer. Named rather than deleted: a P&L feature could have been specified against it. Open, routed not decided: should a deposit retain its entry NAV? Cost-basis P&L is not derivable without one. → product.

Two more stale claims corrected while trimming.

  1. Phase 3 still described admin approval as the gate — "Admin reviews redemption queue… if sufficient, calls approveRedemption" — which v3-82 reversed: the reserve check happens inside requestRedemption, and approveRedemption survives only as an optional manual settle. 07 has been right since v3-82; 05 was the straggler.
  2. treasuryWallet has no timelock, and proposeTreasuryChange does not exist. 05's fee-destination step said the treasury is "the immutable timelocked destination (changed only via proposeTreasuryChange + 7-day timelock)" and "one destination across all pools". grep for proposeTreasuryChange returns 0 hits; the real setter is GovernanceLib.setTreasuryWallet, an instant DEFAULT_ADMIN_ROLE write whose own comment gives the reason ("fees auto-distribute with no hold, so delaying a destination change can't halt a wrong-address payout — it only traps the fix"), and s.treasuryWallet is per-pool storage rather than one global. 04 → Fee Destination already stated both correctly, so the step is now a link there.

Refs: 05-investment-lifecycle · v3-114 (the same pass on 04) · v3-02 (points here for the webhook) · v3-82 (the redemption branch) · 08a → reinvest (gained the legacy one-argument signature note) · 17-changelog. Spec Claude-Plan/Active/05-investment-lifecycle-readability-spec.md (local, not in git). No schema, contract or product change. Source: JY (product), 2026-08-06.

v3-114 — 04-pool-models is a concept spec, not an archive: archaeology moves to its owners ✅ Decided · docs only · 🔴 one fabricated API named

v3-114 — Trimming 04 to the present tense, and what the trim exposed

Date: 2026-08-05

Decided — 04-pool-models states what a pool is now. It defines the dimensions, groups them, and describes the flows in the present tense. Anything that explains why a thing changed, quotes a migration number or a ship date, or restates a mechanism another page owns is a link, not a paragraph. 1365 → 1134 lines with no fact removed from the docs: every cut was verified present in its owner first (14 / 17 / 05 / 06 / 07 / 08 / 08a / 10 / 11), and the one item that lived only in 04 was moved into v3-106 before being cut — how POST /tranche-writedown applies NAV (sibling of propose, no nav_proposals row, per-pool 24h timelock, 207 on a partial fan-out).

Owner map used (worth reusing on the next page): NAV / FX-not-in-NAV → 06 · redemption & epoch mechanics → 07 · contract internals → 08 / 08a · migrations & deleted columns → 11 / 17 · "why / previously / reversed" → the relevant v3-NN · dated ship logs → 17. Within 04, the surviving duplicates were collapsed to one home each: custody table (Core Principles), fee mechanics (How Yield Works), wind-down formula, reserve-vs-tranche, tranche "contract impact: zero".

The two near-identical mutability tables are now one. "Mutability Matrix (Summary)" grouped fields by mechanism and "Editability After ACTIVE" answered what locks when — the same question twice, 93 lines. One table now carries both, with epoch_duration_days and the NAV-safety bounds added (they were in neither, though the old heading claimed "all 24 dimensions"). Both old anchors are preserved#editability-after-active had 6 inbound links from other pages and #mutability-matrix-summary one, so the heading carries the first as an explicit id and the second as an <a id>.

Three stale claims surfaced by the pass, all verified against code and corrected:

  1. is_hidden does not hide a pool from investors — 04 said the pool "disappears from the investor list and its PDP 404s". pools.get.list applies .eq('is_hidden', false) inside the operator branch only, and isPubliclyVisible does not consult the flag, so the investor PDP serves a hidden pool normally. 10-status-machines already recorded this correctly ("admin-list only — narrower than the decision text"); 04 and the mutability row were the stragglers. The narrower behaviour is arguably the right one — a holder must reach the pool to redeem, claim and read a write-down notice.
  2. v3-02 claimed a partner notification that was never built — the card ended "Partners are notified via API/webhook", which is the same fabrication removed from 05 earlier today. Corrected in place with the grep evidence.
  3. EXTERNAL_PARTNER is not a value — 04 called wind-down "the inherent risk of EXTERNAL_PARTNER pools". The identifier appears nowhere in apps/infra/db, apps/infra/lambda or apps/contract/src. Now "partner-operated pools".

🔴 And a second fabricated partner API, in 08-smart-contracts → Partner Notification. It documented POST {partner_endpoint}/aset-governance-action plus a full example payload including can_object_until, implying a contractual objection right during the governance timelock. grep for partner_endpoint / aset-governance-action returns 0 hits repo-wide, and pools.post.governance.ts sends no notification of any kind — not to a partner, not in-app, not by email. The on-chain events are real (GovernanceLib emits FundWalletChangeProposed and six siblings), so "the partner watches the event" is the honest statement and is now what both pages say. Each invented element is named in place rather than deleted quietly, since a partner could have built against that payload. Open, routed not decided: is a governance notification wanted? A 7-day timelock is only a review window if someone is told it started. → product.

Refs: 04-pool-models · 08 → Partner Notification · v3-106 (gained the NAV-application mechanics) · v3-02 (corrected) · 10-status-machines → the hidden axis · 17-changelog. Spec Claude-Plan/Active/04-pool-models-readability-spec.md (local, not in git). No schema, contract or product change. Source: JY (product), 2026-08-05.

v3-113 — Backend docs IA: one organizing axis, ten groups ✅ Decided · 🛠 Done

v3-113 — The sidebar groups by kind of document, not by proximity to a feature

Date: 2026-08-05

Decided — the sidebar has one organizing axis: orientation → identity/compliance → money concepts → contracts → backend runtime → reference → operations → frontend → meta. Every group answers the same question, what kind of document is this, which makes a new page's home derivable instead of negotiable.

Ten groups. Getting Started · Identity & Compliance · Protocol & Money · Contracts · Platform / Backend · Reference · Operations · Frontend · Decisions & Roadmap · Team. All 28 pages sit in exactly one of them.

The non-obvious placements, with their reasons. Recorded here because each is the kind of move that gets quietly undone by whoever next adds a page:

  • 08 + 08a → Contracts (new). Contract implementation and code reference are a different kind of document from the concepts in Protocol. Housing both under Protocol made that group mean two things at once.
  • 20 Joob Pool Config → Reference. It is one fund's configuration — an example to look things up in, not general protocol.
  • 09a Custody → Protocol & Money. Non-custody and money-path immutability are money concepts, not platform operations.
  • 03 KYC + 21 Holder Verification → Identity & Compliance (new), out of Getting Started. They are identity mechanics. A new reader does not need them to orient, and their sitting in Getting Started is what made that group the default home for anything unplaced.
  • 11 + 15 + 24 → Reference (new). Schema, API reference and the field-governance matrix were scattered through Platform, when all three are consulted rather than read.
  • 26 Glossary deliberately stays in Getting Started. It is an orientation tool for a first-time reader, not a lookup appendix. Stated explicitly so the Reference move is not extended to it later by symmetry.

Scope: navigation only. The change is confined to .vitepress/config.ts — content, filenames and anchors have a zero-line diff, so no anchor can have moved. ko mirrors the same ten groups in the same order and gained the four entries it had been silently omitting (26 · 08a · 22 · 25) as new translation stubs. Existing ko files, full mirrors included, were untouched: a stub must never overwrite a translation.

Verified before commit: 28 EN pages each in exactly one group (0 missing, 0 duplicated), EN and ko link order identical, every ko entry resolving to a real file, build green, and check-doc-anchors unchanged at its baseline of 1187 checked / 2 broken (both allowlisted) / 0 unexpected.

Refs: docs/vitepress/.vitepress/config.ts · spec Claude-Plan/Active/protocol-ia-restructure-spec.md (local, not in git) · 17-changelog. No content, schema, contract or product change. Source: JY (product), 2026-08-05.

v3-112 — The hold-back is a dormant lever, and "the 90%" is a configured remainder ✅ Decided · docs only · 🔴 one contract question routed

v3-112 — Naming what is built-but-unpulled, and what the partner actually receives

Date: 2026-08-05

Two naming decisions, from a code audit of the hold-back mechanism. The audit found no bug in the shipped path and produced no product change; what it found was that the docs had no word for a mechanism in this state, and a habit of quoting a configured parameter as a constant.

D1 — "dormant lever" is the standing label for built-but-unpulled. It means: the switch exists on-chain, is tested, and works, but no product surface pulls it, so the behaviour below it never runs on a live pool. It explicitly does not mean removed, broken, or automatic. Hold-back sentences are to be read as contract capability, not as live behaviour. Definition lives in 26-glossary → Status labels.

D2 — write "partner remainder", not "the 90%". The split is reserveAmountRaw = amount × reserveBps / 10000 with the partner taking the rest. 90% is only the figure at the default reserve_bps = 1000. Under the reserve-zero launch assumption the remainder is 100%; a pool at reserve_bps = 2000 sends 80%. Quoting 90% turns a per-pool config field into a constant, and it is wrong for the pools we expect to launch first.

What the audit established about the lever itself (recorded so it is not re-derived): s.fundingRestricted is written in exactly one place (GovernanceLib.setFundingRestricted) and has no initializer, so it is false on every pool; both fill paths behind it (PlatformPool.deposit, YieldLib.reinvest) are therefore unreachable and heldFundReleases is 0 everywhere; setFundingRestrictedOnChain has no caller in any lambda, scheduler or admin screen. The consumer side is fully built and idle — the indexer watches FundingRestrictedSet, business/holdback-release.ts decides the clamp notice, and holdback_release_deferred sits ⏸️ ON HOLD in the catalog. This restates and does not change v3-100.

⚠️ Superseded on 2026-08-13 by v3-128, which removes it. The paragraph below was right about the cost of deleting and wrong about the cost of keeping — see there.

Not a decision to remove anything. Declining to build the product path is cheap and reversible; deleting the mechanism from the contract is neither. heldFundReleases is read by five contract paths including the wind-down numerator (PlatformPool.sol:1370) and the epoch fill calculation, so the term stays in every formula even while it reads 0 — and the clamp inside _releaseHeldFunds is a real fuzz-found fix, not scaffolding. "We are not building product hold-back" and "take it out of the contract" are different decisions; only the first is made.

🔴 Routed, not decided — the clamp counts three obligations where the backing invariant counts five. _releaseHeldFunds subtracts reserveBalance + redemptionCommitted + unclaimedYield, but unclaimedYield is debited at distribution, so yield already promised to holders and awaiting claim (SUM(pendingYield)) is not subtracted while being physically present and owed — freeNorm is overstated by that sum. Same defect class as the §9-3-b bug the clamp was written to fix, one obligation over. Latent (the lever is dormant, so it cannot fire) and unverified by run (code reading; the fuzz campaign was not re-run against the sequence). Must be settled before any hold-back product work begins. → contract owner.

Also corrected in passing: 26-glossary defined unclaimedYield as "distributed yield nobody has claimed". It is the opposite — yield deposited but not yet distributed (YieldLib.sol:122 credits, :179 debits). The name invites the misreading, which is how the fifth obligation went missing in the first place.

Refs: 23-money-path §6 (the section this rewrites) · 26-glossary → Status labels · 22-notifications · 17-changelog. Restates v3-100; changes no shipped behaviour. Source: JY (product), 2026-08-05.

v3-111 — Reinvest prices at effectiveNav, like a deposit ✅ Decided · contract coded, not deployed

v3-111 — One announced-NAV rule for every capital-moving path

Date: 2026-08-05

Decided — reinvest prices LP at effectiveNav, not navPerToken. In normal operation that is the current NAV, so nothing changes. While a decrease sits in its 24h timelock it is the announced (lower) NAV, which means a reinvest and a fresh deposit of the same size in the same block mint the same number of LP tokens. R2·R3 already set this rule for deposits and redemption requests; this closes the third path and makes it unconditional.

Why the open question is closed this way. The argument for leaving reinvest on the stale NAV was that reinvested yield is already inside the pool, so it has arguably borne the loss once. That does not survive contact with the mechanics: the yield sits in accruedYield, which is a claim on the pool, not a priced LP position — it is not marked down by a NAV change, so nothing has been absorbed on the investor's behalf. Pricing the conversion at the pre-announcement NAV therefore hands the reinvestor fewer LP than the pool's announced book value supports, i.e. it charges them for a write-down twice: once when it lands on their existing tokens, once in the exchange rate on new ones. The path that looks conservative is the one that penalizes.

The reverse framing settled it. R2·R3 exists because pricing at the old NAV inside the window is a free option — but the option only has value in the investor's favour, and here the mispricing runs the other way. So the two paths were never symmetric arguments; one is anti-gaming, the other is anti-penalty, and both point at effectiveNav.

Scope. No new parameter, no config, no governance surface, no DB column. Instant pools only in practice, since epoch pools price at settlement. The reinvest gates are untouched (allowRollover, minReinvestAmount, same-pool only per v3-64) and the E1 money-path parity is untouched — this is the price of the conversion, nothing else.

Coded, not deployed. YieldLib.reinvest now divides by PoolCommonLib.effectiveNav(s), matching deposit and redemption; regression test test_ReinvestPricesAtAnnouncedNavDuringDecrease fails on the old expression (1000 LP) and passes on the new one (1250 LP at an announced 0.80). It is a contract change: poolImplementation is immutable on the factory, so it reaches newly created pools only once a new implementation + factory is deployed — every pool that exists today, including the 2026-08-04 deploy, still prices reinvest at the stale NAV.

Off-chain companion, same change: yield.post.reinvest.ts quotes the announced NAV in its LP cross-check, or every reinvest inside a write-down wider than the 1% tolerance would log a divergence that is only the stale applied NAV. Valuation (p_nav_per_tokeneffective_value) deliberately stays on the applied NAV so one position is not marked down 24h ahead of the rest of the pool.

Refs: 05-investment-lifecycle → Reinvest V1 Policy · 06-writedown-nav → R2·R3 · 23-money-path §3b (where the asymmetry was found) · 17-changelog. Extends v3-109 R2·R3; does not touch v3-64. Source: JY (product), 2026-08-05.

v3-110 — ARCHIVED was never a lifecycle status, and CLOSED has no way in ✅ Decided · BE + FE shipped

v3-110 — Pool close, archive and hide are three axes that had been collapsed into one

Date: 2026-08-04

Context. An audit of how a pool ends found two states that the docs described confidently and the code could not produce, and one word doing three jobs. ARCHIVED was written up as a lifecycle_status value in four places — it is not in the enum at all. CLOSED is in the enum, is documented per-state, and nothing in the codebase can write it. And "archive / delete / hide" were one lever, one audit event, and three different intentions.

A — archive narrows, and a separate is_hidden takes the job it was being misused for. Archive (deleted_at) means retire a finished pool: allowed only from a terminal-ish lifecycle (CLOSED / MATURED / WIND_DOWN, plus DRAFT / DEPLOY_FAILED) with every investor position at zero, and it mirrors an on-chain pause() so an archived pool cannot be reached by a direct deposit() call. Restore requires a reason and is audited.

Today's guard is looser than that in a way that matters: pools.delete.ts checks open positions only when lifecycle_status = 'ACTIVE', so a CLOSED / MATURED / IMPAIRED / WIND_DOWN pool holding live investor positions can be archived right now — the pool vanishes from the surface while people still have money in it. That is the defect, and it is also why the guard was loose: operators needed a way to take a live pool off the list, and archive was the only lever there. So narrowing archive requires giving that need its own control — is_hidden, a free toggle that changes visibility and nothing else: no capability change, no on-chain effect, no lifecycle effect, reversible without ceremony.

The alternative — one lever with a force flag — was rejected. The two acts differ in reversibility, in on-chain effect, and in whether an investor is affected; a boolean parameter on a shared endpoint would put "retire this permanently" and "hide this for an afternoon" behind the same button, which is the confusion being removed rather than a compact expression of it.

Naming is fixed to stop the three-way drift. User-facing surface says Archive (reversible, with reason) and Delete only for the permanent DRAFT / DEPLOY_FAILED path. Audit gains POOL_ARCHIVE / POOL_RESTORE so the log can tell three acts apart — at present hard delete and soft archive both write POOL_DELETE distinguished only by metadata.mode, and restore is logged only as a generic POOL_UPDATE (a bare PATCH setting deleted_at: null) — no reason, no dedicated type, so it cannot be distinguished from an ordinary edit.

B — CLOSED gets a way in: POST /pools/{id}/close plus auto-close at end_date. A documented state with no write path is worse than an undocumented one, because screens, badges and copy are all built for something that cannot occur. CLOSED means fund-raising is over: deposits blocked, redemptions and yield claims untouched. It is reversible (reopen) while the pool is otherwise healthy and end_date has not passed — ending a subscription early is an operational decision, not a terminal one, which is what separates it from MATURED and WIND_DOWN. end_date already exists on pools; B gives it a second job as the automatic trigger, so a pool does not sit ACTIVE past its own stated close date waiting for someone to remember.

C — the Korean term for a wiped pool is 전손; English stays total loss. Three phrasings were in circulation across the PRD, DB comments and docs. Fixing the vocabulary is not cosmetic here: the same event is the trigger for D3's mandatory escalation, and a decision that cannot be named consistently cannot be enforced consistently. On first use, pair it with escalate_flag so the reader can connect the word to the column.

Q2 — a total loss escalates through the on-chain 7-day timelock. This is the same mechanism as v3-109's D3 / T2, reaffirmed from the pool-closure side rather than a second decision: rawNav ≤ 0 must move the pool to IMPAIRED via proposeImpairment → 7 days → executeImpairment (v3-106), not merely raise a flag. Recorded here so the closure policy is readable on its own; do not implement it twice.

D — one status chip, and the auto-clear that makes one chip honest. A pool's badge shows a single dominant state. pool-badges.tsx already documents itself as "Single effective pool-status chip" and then renders a second Paused chip beside the dominant one, so the comment and the render disagree. The resolution is the single chip, because v3-78 auto-clear already guarantees only one flag is live at a time — pause is cleared when impairment, freeze or wind-down executes. Once that holds, a second chip is not extra information, it is a second answer to a question with one answer.

Result — implemented 2026-08-04, apart from one Notion text change. Migrations 0123 (pools.is_hidden) and 0124 (the 전손 comment); nothing else needed a schema change. Three places where the shipped code differs from this card, each deliberate:

is_hidden is admin-list only, not "investor list and PDP". A holder must reach the pool detail to redeem, claim yield and read a write-down notice — hiding it there would put their money behind a decision made for the operator's convenience. A pool that must leave the investor's view is a lifecycle question (CLOSED, archive), not a display flag.

The auto-clear in D needs an on-chain unpause(), which this card does not say. v3-92 is explicit: a DB-only clear leaves paused() == true, deposits revert invisibly, a later pause() fails EnforcedPause() and the UI offers no way back. That stranded Test Pool 260616-base. Both the manual close and the lifecycle scheduler now call clearOnChainPause() and write is_paused = false only if the chain agreed — and the scheduler runs unattended, which is what turns this from advice into a requirement.

ACTIVE/UPCOMINGCLOSED, not "any state". 10-status-machines had it as any state; closing a MATURED or WIND_DOWN pool moves it backwards out of a state it reached for a reason, and only a pool still taking subscriptions has a subscription to close. The end_date scheduler pass also runs after the maturity pass, so a FIXED_TERM pool past both leaves as MATURED — demoting it to CLOSED would reinstate early-exit penalties on holders who had just earned their way out of them.

G2 took option ① (the deploy worker carries a pending pause onto the new contract) rather than ② (refuse the pause). ② would have removed the one control an operator has during the window they most need it. On failure the DB flag is left alone and the audit records outcome: failure, since clearing it to match the chain would silently reverse the operator's decision.

G4's blast radius was measured, not assumed: zero OPERATOR pause events in the audit log, so narrowing the role broke no workflow. Two more questions the DB closed — the MATURED + PAUSED ghost has exactly one instance and it is already archived, which under A is the correct state rather than a ghost (its contract agrees); and there are no archived DRAFT rows, so G6's stranding side effect has nothing to clean up.

The archive gates live in a pure lib/shared/business/pool-archive.ts with 16 tests, because every rule here is a refusal and refusals fail open. Four mutations confirm the tests bite (ACTIVE-only guard: 7 failures; query param beating DRAFT: 1; dropping WIND_DOWN: 2; single-column S-18: 3). Docs (this pass): ARCHIVED re-described as a visibility axis, the CLOSED reachability gap closed, is_hidden and the two audit events documented as shipped in 11-db-schema. Outstanding: the Notion PRD wording (심각손실 → 전손), outside the codebase.

Two things found while specifying this, both left as-is. pause on a DEPLOY_FAILED pool is DB-only and nothing re-applies it after a successful deploy retry (gap G2) — a real desync, but it belongs to the deploy-retry path, not here. And the is_hidden / is_display_only name collision is unfortunate; is_display_only is immutable and custody-level while is_hidden is a free visibility toggle, so both are documented side by side rather than one being renamed mid-flight. ⚠️ Superseded 2026-08-20: the rename did happen — 0203 made that column is_showcase (the v3-40 marketing tier) and moved the custody-level meaning to custody_mode (0199). The collision this paragraph declined to fix is resolved.

Refs: 10-status-machines · 04-pool-models · 15-api-reference · 11-db-schema · 09-rbac · 06-writedown-nav · 17-changelog v3-110. Reaffirms v3-109 D3/T2 as Q2; relies on v3-78 auto-clear for D; v3-106 for the impairment timelock. Notion "Pool 상태 종료·보관 정책 재정리". Source: JY, 2026-08-04.

v3-109 — Reserve is not a loss layer; the NAV denominator is totalSupply ✅ Decided · BE + contract shipped (new pools only)

v3-109 — NAV finalized: the buffer absorbs loss, the reserve provides liquidity

Date: 2026-08-04

Context. A full review of the NAV logic against the deployed code found that the model documented since v3-16 had the loss waterfall wrong in a way that favoured investors on paper and produced a drifting NAV in practice. Ten items were settled; the first three are the ones with teeth.

All ten are implemented. The backend half shipped in commit 0abe168 (2026-08-04) and the contract half deployed the same day; reinvest pricing followed on 2026-08-06. Read the Status column in the table below per item rather than the card as a whole, and note the one caveat that applies to every contract-side item: a pool is a non-upgradeable Clones proxy, so a contract change reaches newly created pools only and nothing migrates an existing one.


The ten decisions

#DecisionWhere it livesStatus
R8The reserve never absorbs loss. Order is collateral → buffer → Junior → Mezz → Senior → NAVnav-formula.ts, NavLib.updateNAV✅ shipped — BE sends reserveConsumed = 0 on every path and the contract reverts InvalidAmount on anything else
R9Ordinary NAV denominator is totalSupply, not total_depositednav-formula.ts:242, nav-suggest.ts✅ shipped — reads pools.lp_total_supply; declines to price the pool when the mirror is NULL/0 rather than falling back
R10Wind-down denominator excludes settled-but-unclaimed LPPlatformPool.executeWindDown✅ shipped 2026-08-04 — ⚠️ new pools only
R6Manager equity buffer becomes real config (rate + direction + basis)migration 0118✅ shipped — rate is 0 on every pool, a supported resting state, not a pending value
R7FX is not an input to NAV; the crossing is a ratio, never a ratelossRatioFromFundReport✅ shipped
R5 · R5.5No Aset-authored provisioning curve; delinquency alone never moves NAVnav-suggest.ts✅ shipped — the OJK ladder stays a deferred template (v3-13 amended)
R2 · R3The announced NAV prices everything inside the 24h windowPoolCommonLib.effectiveNav✅ shipped — deposit + redemption 2026-08-04, reinvest 2026-08-06 (v3-111)
D1Manual NAV paths become ADMIN / SUPER_ADMIN (OPERATOR removed)nav-changes handlers✅ shipped
D2A proposal can be dismissed with a reason, and carries a 48h TTLnav-proposals.scheduler.expire✅ shipped
D3 / T2rawNav ≤ 0 escalates to IMPAIRED via proposeImpairment → 7d → executeImpairmenton-chain (v3-106)✅ shipped

Why each one

R8 — the reserve is removed from loss absorption. reserve_bps of every deposit stays in the Pool contract, which means the reserve is investor money, already inside the claim the NAV denominator prices. Subtracting it from a loss as well credited investors twice for their own capital: a $100k loss against a $100k reserve was recorded as no loss at all, while the pool's assets had in fact fallen by $100k. The absorption order is now collateral → buffer → Junior → Mezzanine → Senior → NAV, with no reserve step, and uncoveredLoss = max(0, cumulativeLoss − buffer). The reserve keeps its two real jobs — paying redemptions, and setting the wind-down floor. This supersedes the first-loss framing in v3-16 and BD4; the 10% sizing stands, reinterpreted as a liquidity target.

R9 — the NAV denominator is totalSupply, not total_deposited. The two diverge the moment anyone redeems: burning LP shrinks totalSupply while total_deposited only ever grows. Dividing a fixed loss by a growing cumulative figure meant every new deposit diluted the recorded loss and NAV crept upward with no recovery behind it — a real drift, not a rounding artifact. totalSupply is the number of claims that exist right now, so NAV × totalSupply stays equal to what the pool owes. ✅ Shipped: the denominator is pools.lp_total_supply, the indexer's on-chain mirror, and usableTotalSupply returns null — the sweep skips the pool — when that mirror has never run or reads 0 against live principal, because falling back to netPrincipal would silently restore the very denominator this replaces. Note this reverses the 2026-07-27 lock on total_deposited recorded in A4. ⚠️ The numerator's principal term is pools.tvl, net of redemption payouts — the "only grows" wording below is the label, not the column (glossary).

R10 — the wind-down denominator must exclude settled-but-unclaimed LP. v3-100 (D-1) fixed the wind-down numerator to subtract redemptionCommitted and shipped. The denominator was left as totalSupply, which still counts the escrowed LP backing exactly those payouts — so their USD is out and their tokens are in, and every remaining holder is underpaid by that slice. Target: distributable / (totalSupply − settledUnclaimedLp), where the new counter increments at settlement (not at request) and decrements at claim/burn. ⚠️ Scope discipline: this excludes filled, unclaimed LP only. Pending and rolled-over LP stays in the denominator — their USD is in the numerator too, so they are already symmetric, and removing them would break the pro-rata participation the epoch design guarantees rolled-over requesters. Required a contract change and redeploy — both done: settledUnclaimedLp ships with commit 0abe168, deployed to dev 2026-08-04 (⚠️ new pools only). Do not merge with D-1.

⚠️ Three denominators, three formulas — do not unify them:

UseDenominatorDecisionStatus
Ordinary NAVtotalSupplyR9✅ shipped (off-chain — pools.lp_total_supply)
Yield accrualtotalLpSupply − poolHeldLp — the RECOGNISED debt onlyv3-131 (3)✅ shipped 2026-08-20 — new pools only. ⚠️ Same expression as Option A (v3-104), opposite rule. Option A meant "escrowed LP earns nothing"; here the holder keeps earning and only the booking is deferred to the escrow's boundary — previewEscrowAccrual(requestId) is that lag. settledUnclaimedLp is the wind-down term, not this one
Wind-down NAVtotalSupply − settledUnclaimedLp — settled-unclaimed onlyR10✅ shipped 2026-08-04 — new pools only

All three are different now, and that is the point. Ordinary NAV excludes nothing, and it is the one that must never move. Yield accrual excludes all pool-held LP, because an escrowed holder keeps earning and their entitlement is booked separately at the escrow's boundary. Wind-down excludes only the settled-unclaimed slice, because pending and rolled-over LP is already symmetric with its numerator. Three separately-argued rules that merely share a shape — folding any two into one term reinstates the rule the other one rejected.

R6 — the manager equity buffer becomes a real parameter. equity_buffer_rule has been free text ("Manager absorbs NPL up to 5%") — a promise to investors that no formula could act on, which is how R8's removal of the reserve would otherwise have left standalone pools with no first-loss layer at all (reserve is 0 at launch by design). The buffer is now per-pool config (migration 0118): bufferCap = total_deposited × buffer_rate_bps / 10000, plus buffer_direction and buffer_basis. GROSS means the partner reports total loss and we subtract the cap; NET means the loss already reflects their absorption and the cap is forced to 0, because subtracting it again would repeat the exact double-count R8 just removed. The base is pool size, deliberately not NPL: per R5.5 delinquency alone never moves NAV. ⚠️ A depleting bufferBalance was specced and deliberately not builtbufferBalance = cap − absorbed then uncovered = loss − bufferBalance subtracts the absorbed amount twice (at loss = cap = 100 it reports 100 uncovered instead of 0). The computation is absolute, so the buffer is stateless and needs no buffer_cap or buffer_balance column; a regression test pins the wrong version out. The direction became a column rather than a question to Joob. Rates and direction are still unconfirmed externally, but a wait with no end date is not a safeguard on a formula that runs every 12 hours — and the two directions are opposite liabilities with the same number (5% cap, 8% loss → investors take 3% or 5%). Defaults reproduce today's arithmetic exactly, so an unconfirmed pool has bufferCap = 0 and an answer is an UPDATE.

R7 — FX is not an input to NAV. For a pool whose assets are in a local currency, NAV measures performance in that currency; the USD conversion happens at redemption, at the then-current rate. The alternative (NAV in live USD) was rejected because it makes NAV move when nothing about the assets has changed, mixes write-down and currency effects in one number, and forces a carve-out from the deviation cap and the 24h timelock for moves that carry no information. Consequence: a $1.00 NAV on a non-USD pool is not a claim about present USD value, so screens must show the reference conversion alongside it. Also settles the open question from the review — an FX move never queues a timelock, because NAV did not move.

R2 · R3 — the announced NAV prices everything inside the timelock window. A pending decrease is public for 24 hours, and pricing new activity at the old NAV during that window is a free option in both directions: buy at $1.00 knowing $0.98 lands tomorrow, or exit at $1.00 and leave the loss with whoever stayed. Epoch pools are immune (they price at settlement), so this is an instant-pool defect. Decided: deposits and redemption requests submitted during the window price at the announced NAV. Snapshots taken before the announcement stay valid — this is a carve-out for the window, not a repudiation of forward pricing. This resolves a live contradiction: a 2026-07-02 verbal decision said trading was blocked during the timelock, while this page and 06 continued to say investments "stay open" at the current price; neither was implemented, and the answer is neither of them.

R5 · R5.5 — no Aset-authored provisioning schedule. Aset mirrors the fund's write-off, and does not invent a partial-provisioning curve on top of it (the OJK 5/15/50/100 ladder stays in the docs as a deferred automation template, not a policy). Delinquency alone never moves NAV; only a realized write-off does. Bucket boundaries remain a dynamic array in config so any partner's schedule can be ingested as reported. Joob's actual boundaries are an open data question, not a design one (v3-90).

D1 · D2 · D3 / T2 — governance around a NAV move. D1: the manual paths (POST /nav-changes, POST /tranche-writedown) become ADMIN / SUPER_ADMIN with OPERATOR removed — A4's approve/override was already ADMIN-only, so leaving the hand-typed route open to OPERATOR made the weaker control the effective one. D2: a proposal can be dismissed with a recorded reason and carries a 48-hour TTL, so one nobody acted on expires instead of wedging the next sweep (the material gate refuses a second open proposal). D3: rawNav ≤ 0 must escalate to IMPAIRED rather than merely raising escalate_flag — a wiped pool should not sit at ACTIVE still taking deposits. T2: that escalation runs on the existing on-chain proposeImpairment7-day timelockexecuteImpairment (v3-106), which replaces the two-person approval previously floated. Seven public days beat two simultaneous private signatures, and the path already preserves the exit right (deposits blocked, redemptions open). D4: investor-facing charts standardize on nav_per_token. FE: a NAV decrease links to the write-off that caused it instead of appearing as an unexplained number.


Implementation notes — six defects the review had not predicted

Building it found these. Ranked by exposure, and each one is the reason a decision above reads the way it does:

🔴 R7 was not a display task, and a healthy pool was one approval from being priced at zero. The suggestion path subtracted the partner's cumulative_loss, reported in EFIDR, from the pool's USD principal: 1,022,311,789 minus 573,206 gives −1782, which clamped to the 1e-6 floor and set escalate_flag. The only NAV proposal dev ever produced was a total-loss write-down on a pool whose loans were all performing. The crossing is now a ratio and deliberately not an exchange rate — cumulative_loss ÷ total_subscribed, both from the same report, so the currency cancels and no FX rate can reach NAV. That is what makes R7 enforceable rather than a convention.

🔴 The indexer had been dead for 13 of the last 45 days, which is the real reason R9 could not simply be switched on: a denominator read from a chain nobody is listening to is worse than a stale one in the DB. Alchemy caps eth_getLogs at 10 blocks and the engine asked for more, so every run threw. Adaptive range that shrinks on the provider's own error, and the reserve/LP mirror moved outside the event pipeline so a log failure cannot take the mirror with it.

🔴 The reserve mirror was off by 1e12reserveBalance() is an 18-decimal counter and the indexer divided by 1e6, recording a pool holding $2.10 as $2,100,000,000,000. Invisible because the indexer was also dead. Branded RawAmount / NormalizedAmount / NavPrice types plus a build guard now put the axis in the type system instead of a comment.

🔴 Wind-down charged the same exit twice. R10 was logged as one asymmetry, in the denominator; the numerator had the mirror image. _reserveFilledGross already parks settled gross in redemptionCommitted, so executeWindDown subtracting it again returned max(0, 50 − 100) = 0 for a pool holding $50. Both sides fixed together.

And the test count was lyingtsc leaves output for deleted sources, so 18 tests from files that no longer exist kept running out of dist (284 reported, 266 real). Reproduced with a planted ghost file; rm -rf dist now precedes build and test.

R6 shipped as configuration rather than as an answer. It was parked on the partner's contract terms, which is a wait with no end date on a formula that runs every 12 hours. What the wait protects against is guessing, and configuration does not guess: 0118 adds buffer_rate_bps, buffer_direction and buffer_basis to pools, defaulted so the arithmetic is bit-identical to before. Direction is the column that earns its place — with a 5% cap and an 8% loss, FIRST_LOSS hands investors 3% and EXCESS hands them 5%, and a formula that silently assumed one would be wrong half the time with no symptom on any screen. Basis (GROSS/NET) is R8 one layer up: deducting a buffer from a figure already reported net of it double-counts, so NET forces the cap to 0. 0119 makes an inert buffer unrepresentable (buffer_rate_bps > 0 requires external_fund_id — the sweep never visits an unmapped pool), and 0121 subordinates the prose to the config: equity_buffer_rule cannot be set where no layer exists, so the investor line "Manager first-loss commitment" can no longer describe nothing. fund_report_cadence_days and apy_basis ship the same way.

A guard was added and removed inside two days. 0118 also carried nav_proposals.recovery_flag, warning the operator when a proposal RAISES NAV, on the argument that increases apply with no timelock and so cannot be caught during a notice period. That argument describes the timelock rather than a missing control: approval is already a deliberate human act, and a second banner on the same click changes no decision. Product's call is that a NAV increase takes the approve button and nothing else. Dropped in 0120 rather than left unwritten — an always-false column reads to the next author as a signal that means something.

Q1 answered itself in the source, and it moved R2·R3 on-chain. deposit() and requestRedemption() are external and KYC-gated, so any holder calls them straight from their wallet; a backend gate would have bound the people using the UI and nobody else, which is precisely the wrong half. PoolCommonLib.effectiveNav() returns the announced value while a decrease is queued and four pricing sites take it — deposit (a buyer in the window gets more tokens at the lower price rather than an instant loss), the instant redemption gate, the navAtRequest snapshot, and settleNav. That last one was not in the decision, which called epoch pools already satisfied by settlement-time pricing: during the notice window the stored NAV is stale by construction, so an epoch settling then paid its exiters the pre-write-down price out of liquidity the remaining holders still owned. Same hazard, same fix.

Two of this card's own blockers were not real. nav_proposals and migrations 0090/0091/0093 are all present in dev — the (pending apply) markers were stale, exactly the failure mode 11-db-schema warns about. T1/T2/D-1 were likewise recorded as outstanding but had shipped in v3-106.

And R6 turned out to be a one-pool feature, which the constraint protecting it had made permanent. Shipping the buffer as configuration was only half the job: buffer_rate_bps was read in exactly one place, computeSuggestedNav, which the sweep calls for externally mapped pools — 1 of the 13 standalone pools. On the other 12 an operator could configure a first-loss layer, watch it save, and have it absorb nothing. 0119 responded by banning the value there, which was correct while nothing could read it and wrong as a resting state: it froze the buffer into a feature for one pool.

The reason no buffer could reach the other 12 is that the manual path takes a price, and a hand-typed price has already had the loss applied by whoever did the arithmetic — there is nothing left for a first-loss layer to subtract. So POST /nav-changes now accepts cumulative_loss as an alternative to new_nav and derives the price through computeNavFromPoolLoss, the same entry point the sweep uses. Both callers, one composition, no second place for the buffer to quietly not apply. 0119 narrows to buffer_not_on_tranche_pools (a tranche group prices through the waterfall engine, so it genuinely has no place to apply one) while fund_report_cadence_days keeps the mapping requirement, because unlike the buffer it describes the partner's obligation to send reports and a pool with no feed receives none.

Three guards came with it, each one a failure already on record: a loss above total_deposited is refused (which is also what catches an R7 currency mix-up on this path — a rupiah figure overshoots by orders of magnitude and would otherwise clamp to the floor and read as a legitimate wipeout); a computed wipeout routes to proposeImpairment (D3) here as on the approve path; and the admin preview is a server dry_run that runs the formula and the on-chain simulate, rather than a copy of the formula in the browser, which would have been a second place for the two to drift. nav_history gains loss_amount (the uncovered part, after the buffer) and loss_as_of, so a NAV drop can name its own cause — the decrease-cause metadata that had been spec-level.

Shipped: R6 (on every pool), R7, R8, R9, D1, D2, D3 + the six defects above. forge test 375/375 including the invariant suite; infra 307/307; PlatformPool 20,835B against the 24,576B limit (3,741B margin). Waiting on the dev deploy: R10 and R2·R3 are written and tested but live in the contract. R9 now has its mirror column (pools.lp_total_supply, 0117) and switches once that mirror is observed running — numerator in the DB and denominator on the chain turns every indexer stall into a NAV jump, which is the failure this round already lived through. R9 shipped, and the redemption case turned out to be worse than the deposit one the decision recorded. The card describes the defect as new deposits diluting a recorded loss (0.900 → 0.947). Working through the algebra with the mirror finally populated showed the other direction is live money: on a $1,000 pool written down to 0.90, a 500-token redemption paying $450 left the old formula at 0.8182 — the leaver's write-down charged a second time to everyone who stayed — and the next deposit refunded part of it back to 0.9310. Neither move had anything to do with the assets. Dividing by lp_total_supply makes both exactly cancel: with p = (V − L)/S, a redemption takes V to V − T·p and S to S − T, so the price recomputes as p(S − T)/(S − T) = p.

🔴 And that cancellation depends on the numerator being NET of redemptions, which contradicts this card's own wording. complete_redemption_atomic does tvl = tvl − payout; the text above calls the term total_deposited and says it "only grows". It does not, and it must not — a cumulative figure would keep the exited investor's principal in the numerator while their tokens left the denominator, inflating NAV on every exit. The description was wrong, not the column.

A missing supply is not a zero one. usableTotalSupply() refuses NULL, 0 and negatives, and callers skip the pool rather than falling back to the principal — that fallback is the old denominator, and nothing on any screen would distinguish it. Dev has four pools in that state, one of them holding $7.1m against an on-chain supply of 0. The manual path keeps new_nav available for an operator who has a figure from elsewhere, which keeps the fallback a visible human act.

⚠️ R9 also turns ledger drift into a price. The old form returned exactly 1.0 at zero loss for every pool regardless of data quality, so it could not surface anything; one dev pool carrying $2.30 against 19 tokens now prices at $0.121. That disagreement was always there. Dry-run over all 13 live pools: 8 unchanged at 1.0, 4 skipped by the guard, 1 exposed — and the suggestion sweep visits none of them, so the switch lands inert.

The last untested surface was closed, and the reason it had stayed untested was not the one on file. computeSuggestedNav has fifteen branches and every refusal returns the same null, so a bug that turns "cannot price this pool" into "priced it wrong" shows up nowhere. The note explaining the gap said the repo had no Supabase mocking pattern. The actual blocker was cruder: db/supabase.ts throws at module load when its env vars are absent, so a static import of the module under test dies before any test body runs. Placeholder env plus a dynamic import clears it, deliberately in the test file rather than the test script — a blanket fake URL there would also silence a genuinely missing configuration everywhere else. The function now takes its client as a defaulted argument, so production carries no cast and the fake is confined to the test.

⚠️ The first version of the direction test passed for the wrong reason. With a loss of 100 against a 50 cap, FIRST_LOSS and EXCESS both leave 50 uncovered — the case agreed with either branch, including a wrong one. Rewritten at a loss of 80, where the two split 0.97 against 0.95. Three mutations confirm the suite bites: inverting the direction fails 15 cases, making the supply guard fall back to the principal fails 4, and restoring the pre-R9 denominator fails 4.

Nothing is waiting on the partner. The five terms once filed as "cannot proceed without Joob" were never inputs the system lacked — they are settings with defined defaults, and the unset state is a supported behaviour rather than a gap. The handoff document was retired on that basis. What the defaults mean, stated once so nobody reads them as safe: buffer_rate_bps = 0 puts nothing but collateral between a realized loss and investors, which is the confirmed launch condition; a NULL fund_report_cadence_days means the product does not tell investors when the next update lands, because the observed rhythm (7 reports in 7 months, irregular) is not a promise anyone made; apy_basis = GROSS_DEPOSIT is indistinguishable from the alternative while the reserve is effectively 0, and needs revisiting when it is not; and a NULL write_off_policy changes no arithmetic, since the partner decides write-off timing and Aset mirrors the figure. Agreeing a real buffer with any partner is a PATCH, and it must carry the direction as well as the rate — the same 5% leaves investors with 3% or 5% of an 8% loss depending on it.

Refs: supersedes the loss-absorption half of v3-16 and the premise of BD4; amends A4 (both formula and denominator lock); extends v3-100 D-1 to the denominator; leaves v3-104's yield denominator untouched; uses v3-106's impairment timelock. 06-writedown-nav · 26-glossary · 11-db-schema · 17-changelog v3-109. Notion "NAV 정리본". Source: JY, 2026-08-04.

v3-108 — Notifications rebuilt: one row doing two jobs becomes event → notification → delivery ✅ Decided · shipped

v3-108 — The notification system, rebuilt to the current requirements

Date: 2026-07-31

Why rebuild rather than extend. The system was an initial implementation with requirements layered on top of it, and every awkward part traced to one decision: notification_logs was a single row that was BOTH the in-app inbox item and the email delivery log. That is what made an item the investor could read carry status = 'FAILED' (the status described the email); it is why SUPPRESSED had to be invented to mean "email skipped, in-app fine"; it is why the WEBHOOK enum value was unusable (one channel column, so a second channel meant duplicating the payload); and it is why recipient_id meant user_id or pool id or fund id depending on the row — which forced an 80-line resolver into the send worker and let an FM notice for a pool with no fund_id reach nobody, silently. Asked to design it from scratch against today's requirements, the answer is the conventional three-table split, so that is what shipped.

The model. notification_events (what happened, idempotency_key UNIQUE) → notifications (who is told — the inbox item, no status) → notification_deliveries (how it went out, one row per channel), plus email_suppressions. Migration 0110 drops notification_logs with no backfill: the rows are pre-launch operational history, and a parallel legacy table would have preserved the exact ambiguity the split removes.

Fan-out moved to produce time, which is the decision with the most consequences. An ADMIN notification used to be one broadcast row whose audience was resolved when the email was sent; it is now one row per person, written by the producer. So read state is per person (a teammate opening an alert no longer clears it for the team — this replaces "model A: shared read", decided here at CH's call), preferences apply where the channels are chosen (an opt-out means no delivery row, so it cannot appear in a failure count, and SUPPRESSED is not needed), and an unresolvable audience fails at the call site. It also deleted the admin feed's fund-isolation logic outright: an FM cannot see another fund's rows because those rows are addressed to other people.

Audience became a property of the event. Each catalog entry declares audience: ['opsTeam', 'fundManagers'] and a producer passes only parameters ({ poolId }). Previously "who receives this" was decided by which helper a producer happened to call, so the copy sheet's recipient column recorded an intention the code could contradict — and did. Deriving the audience mechanically from all 32 producers to build the catalog surfaced one live instance: fund_member_changed ("Your role and permissions have been updated") resolved to the whole ops team unless the send worker special-cased an ADMIN row carrying an address. It is now adminUser, the one member whose role changed.

Dedup became a constraint. idempotency_key UNIQUE replaced four fail-open lookups (dedupByEntity, recentlyNotified, and two hand-rolled notification_logs queries) that each let duplicates through whenever the lookup itself errored. Recurring alerts encode their cadence in the key — epoch_gated:<pool>:<epoch>:2026-07-31 is "once a day" as a property of the key rather than a window a caller has to remember to pass.

Render timing now differs per channel, deliberately. In-app renders at write time and is stored (the feed does not depend on the catalog, and editing copy does not rewrite what someone already read). Email renders at send time from the catalog plus stored variables, so a copy fix reaches anything still queued and a thousand-holder distribution stores a thousand rows of variables instead of a thousand copies of the same HTML document.

Delivery is a queue. SQS + DLQ replaces a 3-minute cron that polled a batch of 100 and used a claimed_at column to stop overlapping runs double-sending — SQS gives that for free and removes the ~2,000/hour ceiling that made a thousand-holder distribution take half an hour to drain. Backoff exists now (1/5/15/60/240 min); the old worker released a failed row immediately, so three attempts burned in nine minutes and a brief SES outage permanently failed everything caught in it. A 5-minute sweep is the outbox-relay fallback for a delivery committed but never enqueued.

🔴 The bounce gap, closed. SendEmail succeeding only means SES accepted the message. Nothing listened for the bounce: the configuration set existed, notification_failure_type already had BOUNCED / SPAM_FILTERED, and SendEmailCommand never named the set — so every bounce SES generated was discarded, and DELIVERED was a claim the system could not support. An SNS webhook now correlates feedback by provider_message_id and adds permanent bounces and complaints to email_suppressions; a suppressed address overrides even a critical event, because the mail would not arrive and continuing damages a sending reputation shared by the whole domain. The topic and its Bounce + Complaint event destination both live in SesStack, next to the configuration set, so a deploy wires them; creating the topic in ApiStack had left that one link as a manual per-environment step, and dev ran with a fully deployed-looking pipeline that discarded every bounce.

One deliberate copy-preserving compromise. The pool-update path (operator-written title/body) was the last direct notification_logs insert and rendered its own email. It moved onto two catalog entries whose strings are exactly the ones it already sent, including the chip labels — NotificationEventDef gained an optional chipLabel so the rebuild could not silently reword a live notice. pool_update_material is critical (an impairment or wind-down notice must not be suppressible); pool_update_important is optional, which finally makes real the priority: 'optional' the old hardcoded payload already claimed but nothing read.

Sheet coverage. 52 of 54 sheet rows are implemented. epoch_demand_finalized and over_funding_detected remain blocked on the epoch cutoff-freeze demand snapshot (A1·A2) — without it there is no confirmed payout total, so the notice would state a figure that can still change, which is worse than silence. Adding either later is one catalog entry plus one notify() call. Four sheet-vs-code recipient divergences were found and deliberately not changed (fm_shortfall, holdback_release_deferred, yield_distribution_escalation, nav_change_*_ops): altering who receives an email during a structural refactor is the wrong risk, and one of them would have removed admin visibility of a reserve shortfall. With product.

API compatibility is intentional. The read endpoints return the shapes they always returned, rebuilt from the new tables — a storage change should not become a client change. The ops view maps SENT → DELIVERED, SKIPPED → SUPPRESSED, and FAILED/BOUNCED/COMPLAINED → FAILED (with failure_type carrying the distinction) because the NotificationLog widget comes from a shared design kit. Surfacing bounce and complaint natively there is a front-end follow-up.

Verification: infra tsc clean · 175/175 · copy + producer + units guards clean · cdk synth clean (queue, DLQ, SNS topic and three functions present). The producer guard did its job mid-rebuild: it caught that fm_notification_failed's only producer was the send worker being deleted, and went green when the new worker took over. Deployed to dev and migration 0110 applied 2026-07-31; the bounce path was then verified end to end against live SES (both simulator addresses round-tripped through send → SNS → webhook, correlated by provider_message_id, suppressed with the right reason).

Refs: 22-notifications (architecture) · 11-db-schema · 17-changelog v3-108. Supersedes the shared-read model in v3-44 and the SUPPRESSED state in v3-97's follow-ups. Takes the number after v3-106 because v3-103 reconciled the registry this rebuild then replaced — its pending em-dash sweep over copy.ts now applies to catalog/. Source: CH, 2026-07-31.

v3-107 — The anchored epoch schedule is never installed on-chain; funding-date provenance is a column, not a chain read ✅ Decided · shipped (contract deployed 2026-08-04)

v3-107 — Model B is designed, merged, and not deployed: the write paths that install it are missing

📌 Status update 2026-08-05 — all layers closed; kept split because they landed separately.

ItemThenNow
Two provenance columns + migrationmissing → hard FE blockerapplied to dev (0111_epoch_funding_date_provenance)
redemption_epochs settlement-dueapplied to dev (0112)
The two per-cycle setter endpointsno callerbuiltpools.post.epoch-schedule.ts
Three indexer writers + ABI fragmentsmissingbuiltlib/shared/indexer/writers/epoch-schedule.ts (events only start flowing after the contract deploy)
The on-chain functions those callers invokedeployed to dev 2026-08-04 (factory 0xE1E2E974…DA90 → impl 0x27D9948F…f968, commit 0abe168) — new pools only

So "None implemented" below is no longer true of anything. The contract half closed too: setEpochSchedule / setEpochFundingDate / setEpochSettleAfter are on the implementation deployed to dev 2026-08-04 — new factory 0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90 → new pool implementation 0x27D9948FD2A3aF7b035e99d23440A3A877aef968, built from commit 0abe168, verified by selector. ⚠️ Newly created pools only. poolImplementation is immutable on the factory, so a new implementation means a new factory and existing clones are not upgraded. Every pool created before the deploy (newest: 2026-07-23) still runs the old implementation, and no pool has been created from the new factory yet. And setEpochSchedule is create-only, so a pre-existing pool can never be anchored — it has to be recreated. Card text below is preserved as written. Date: 2026-07-31

Context. v3-105 closed the epoch design questions and the 2026-07-31 sync pass corrected the pages that still described the pre-redesign engine. This is the pass over what was left — the write paths, the read path to the front end, and the four pages the sync pass did not reach. Verified against apps/contract/src, apps/infra and apps/web on 2026-07-31, before the epoch FE work starts.

🔴 The finding that changes the plan — setEpochSchedule has no caller, so no pool is anchored. The setter that installs (fundingAnchor, recallLeadDays, requestWindowDays) is called from nowhere: not the deploy config-call list (lib/shared/contract/create-pool.ts), not any lambda, not the admin app; its three arguments are not even fields on CreatePoolOnChainParams. The backend collects the values (pools.post.create validates them, pools.patch.update treats them as Class B) and they simply stop at the database. Every pool therefore deploys with fundingAnchor == 0, which PoolCommonLib.hasAnchoredSchedule reads as "no anchored schedule": the request-window gate is skipped, the lazy clock returns, and requests are accepted in the gap. v3-100 framed the carve-out as "Model B is a new-pool property, not a platform-wide one" — with this missing it is no pool's property, and the FE premise that deleting the 7 legacy pools leaves only Model B pools is false.

Decided: wire setEpochSchedule into the deploy sequence, and treat the mismatch as a defect rather than a state to support. A pool whose DB carries schedule terms while its chain reads fundingAnchor == 0 is a deploy defect — the deploy path should assert the anchor after the config calls, and the admin pool surface should surface an unanchored epoch pool rather than rendering a schedule that is not enforced. Two constraints, both unforgiving:

  • Ordering is load-bearing. setEpochSchedule validates recallLeadDays + requestWindowDays < epochDurationDays, so it must be appended after setEpochDurationDays or it reverts InvalidSchedule.
  • Create-only. It reverts ConfigImmutableAfterDeposit once LP exists. A pool that takes its first deposit unanchored can never be anchored — the same permanence that keeps the 7 legacy pools legacy. So this blocks pool creation, not just the FE.

Decided — funding-date provenance is two columns, and the FE badge is blocked on them. v3-105 decided the 확정 / 예정 badge reads the database; it did not say where. It is pools.next_funding_date_confirmed_at + next_funding_date_set_by, written only when the on-chain setEpochFundingDate tx confirms, and nulled when settlement advances the cycle — the next cycle's date starts life derived, so provenance must re-arm per cycle or the badge inherits "확정" from the cycle before it. Badge = 확정 iff both are present for the displayed cycle; their absence is 예정, exactly as v3-105 specified. Neither column exists, so this sits alongside previewEpochClaim as a hard FE blocker, not a nice-to-have.

Decided — setEpochSettleAfter is wired in the same batch. It has no caller either, which means the other half of the semi-auto knob — "the publisher is running late, move settlement" — is unreachable in product. Errors map straight through: InvalidSchedule when the value is earlier than the funding date (the knob is delay-only), and SettleAfterTooLate(cap) where cap = fundingDate + recallLeadDays. Surface the cap in the 4xx; the operator has no other way to learn it.

Decided — the indexer needs writers for all three schedule events. EpochScheduleSet (records the terms actually installed, i.e. the assertion above), EpochFundingDateSet (the only exact confirmation signal — it writes the provenance columns), EpochSettleAfterSet (the delay badge). All three are declared in GovernanceLib and therefore absent from PlatformPool.abi.json, so each needs the ABI fragment added to the decoder — which, as v3-105 noted, recovers them retroactively for deployed pools with no contract change. Per v3-92 the DB mirrors the chain, not our intent; without these writers pools.next_funding_date is an intent the chain may not share.

Decided — GET /pools/{id} must return the schedule. DETAIL_SELECT in pools.get.list.ts omits all six epoch-schedule columns while LIST_SELECT carries them, so the investor pool detail page reads request_window_days / recall_lead_days / next_funding_date as null and cannot render a date, a badge, a countdown or the reverse-derived timeline. A one-line fix, listed because it is the difference between the dates existing and the dates being displayable.

Recorded for the first time — C7: a claim never expires. Decided 2026-07-24 and never written down anywhere in docs. A settled share stays claimable indefinitely — no deadline, no sweep, no reversion to remaining holders — because forfeiting an investor's money for the passage of time is untenable in an RWA / regulated context, and the reservation is isolated in redemptionCommitted so an unclaimed payout cannot be spent on another cycle's fill or on yield. The accepted cost is that redemptionCommitted never returns to zero on its own, which permanently reduces the wind-down numerator and tightens the hold-back clamp by that amount. Recorded so nobody later adds an expiry as an obvious cleanup; doing so needs the escheatment / unclaimed-property legal item answered first.

Four pages were still describing the replaced engine (the 2026-07-31 sync pass covered 07-redemption / 23-money-path / 04-pool-models and stopped there), all corrected here:

PageWasNow
08a-contract-reference executeEpoch"No money moves" · "impl pending (CH)" · "per-epoch pot" — wrong three waysCarry-first fills, the redemptionCommitted scalar debit, gate on settlementAllowedAt. Plus the three epoch setters, which the function tables never listed at all (setEpochSettleAfter appeared nowhere in docs)
10-status-machines epoch flow"⚠️ Engine redesign decided — impl pending" + per-epoch potShipped, with v3-105's two contract items and this entry's deploy gap named as what is still outstanding
15-api-reference POST /pools/{id}/freezepropose_extend / execute_extend / cancel_extend listed as live governance actionsThey return 410 Gone; the body's two escalation paths (pause, impairment) are documented
08-smart-contracts eventsFreezeExtendProposed / FreezeExtended / FreezeExtendCancelled as a live timelocked pathMarked removed, with why (7d timelock == 7d freeze lifetime) and what remains (pendingFreezeExtend* storage, cleared on unfreeze)

Result: BE work — deploy wiring (create-pool.ts + CreatePoolOnChainParams), the two per-cycle setter endpoints, three indexer writers + ABI fragments, two provenance columns + migration, one DETAIL_SELECT line. None implemented. Contract: unchanged by this entry (v3-105's ClaimBeforeCancel + previewEpochClaim still stand). FE: the epoch date/badge work is blocked on the provenance columns and the DETAIL_SELECT fix; the partial-fill split stays blocked on previewEpochClaim.

Refs: completes v3-105; corrects the docs half of v3-100; applies v3-92 to the schedule events; 07-redemption → Funding date: confirmed vs derived · 08a-contract-reference · 11-db-schema · 17-changelog v3-107. Notion "Epoch 재설계 — 기획 확정 & FE 작업" §5–6 + "Epoch Redemption — 코드 검수 정리". Source: JY, 2026-07-31.

v3-106 — A tranche write-down is an ADMIN act; IMPAIRED exists only on-chain; only a wiped tranche is impaired ✅ Decided · 🛠 Implemented (BE)

v3-106 — Tranche write-down governance + the IMPAIRED escalation it never performed

Date: 2026-07-31

Context. v3-50 shipped the loss waterfall as POST /tranche-writedown: split one group-level loss by subordination, apply each pool's new NAV. What it does around the NAV write was never specified, and the code checked on 2026-07-31 answers it in three ways we did not intend.

Decision 1 — the endpoint moves to ADMIN / SUPER_ADMIN. It currently accepts OPERATOR (withRole('SUPER_ADMIN', 'ADMIN', 'OPERATOR')), so one operator can mark down every pool in a group in a single call. Note this is not a gap in the A4 approval flow: nav-changes.post.approve / .override are ADMIN-only because they resolve a scheduler-suggested proposal, while propose and tranche-writedown are both direct writes with no second pair of eyes. Marking a group down is the largest of the three, so it takes the highest bar. A4 approval is deliberately not extended to it — a waterfall is one loss decomposed into N NAVs, and routing each leg through an approval queue would let a group settle half-approved.

Decision 2 — a wiped Junior escalates through the on-chain impairment path, not a database write. Today the wipedOut branch writes lifecycle_status = 'IMPAIRED' + impairment_proposed_at = now() straight to pools and calls nothing on-chain. That is the v3-92 defect class again — the database asserting a state the chain does not have — and here it is worse, because it is unrecoverable through the product:

  • on-chain lifecycleStatus stays ACTIVE and hasImpairmentProposal stays false, so every gate that keys off IMPAIRED keeps behaving as ACTIVE. Concretely: RedemptionLib's lockup + penalty waiver does not apply, so an investor in a wiped Junior is still lockup-blocked or still charged the early-exit penalty while the UI tells them distress terms are in force; and the on-chain deposit block does not apply either (the BE blocks deposits, a direct contract call does not).
  • pools.post.impairment propose then 409s twice over (impairment_proposed_at is set, and lifecycle_status is no longer ACTIVE), execute passes its own DB-side timelock pre-check and reverts on-chain with NoImpairmentProposal → 502, and setLifecycleStatus refuses IMPAIRED by design (_isValidLifecycleTransition). There is no path back.

So: wipedOut calls proposeImpairmentOnChain, the DB records proposed only, and lifecycle_status flips to IMPAIRED at executeImpairment after the 7-day timelock — the same route every other impairment takes. If deposits must stop before the timelock elapses, use is_paused (with the on-chain pause(), per v3-92) — not a lifecycle write. Accepting the 7-day delay on the lifecycle label is the point: IMPAIRED is a public solvency signal with an investor-visible waiver attached, and the timelock is what makes it reviewable.

Decision 3 — only complete exhaustion is IMPAIRED. Junior absorbs the whole loss first; when Junior is wiped, only the Junior pool is impaired. A Senior (or Mezzanine) that absorbed a partial loss stays ACTIVE with NAV < 1.0 — a writedown, not a lifecycle change. This is the existing writedown-vs-lifecycle split (v3-12) applied to tranches, and it is why a group can hold an IMPAIRED Junior and an ACTIVE Senior at the same time.

Correction — there is no "NAV floor" mechanism. The NAV > 0 behaviour reads like one guard and is three independent ones: the contract rejects newNav == 0 (InvalidNav) and clamps above $1.00; the DB has CHECK (nav_per_token > 0); and NAV_FLOOR = 0.000001 is a local constant in tranche.post.writedown.ts onlynav-changes.post.propose has no such constant. Epoch settlement's own settleNav == 0 → InvalidNav is a fourth thing again (a corrupt-oracle bug-guard, documented in v3-100). Consequence to hold onto: "NAV 0" is not representable anywhere, so a wiped tranche is 1e-6, and the lifecycle transition — not the number — is what says "effectively zero".

⚠️ Latent conflict: the deviation cap forbids a wipeout. With navDeviationCapBps > 0, 1.0 → 1e-6 exceeds any cap, so _checkNavBound reverts and the handler's pre-flight simulate turns it into a clean 4xx — correct behaviour, but it means a full Junior write-down cannot be applied at all until the cap is lifted. Not live today: nothing calls setNavDeviationCap and the pools.nav_deviation_cap_bps column is unused, so every pool runs with the cap off. Whoever turns the cap on owns this: the operating flow needs an explicit lift → write down → restore step, or the cap needs a documented carve-out for a wipeout.

Also recorded — the wind-down numerator item (D-1) is already shipped, not open. It was carried into the Notion page as a companion fix; executeWindDown already prices liquidation off reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted (v3-100 Correction 2, in merged code). ⚠️ Superseded in part by R10 (0abe168, deployed 2026-08-04): the − redemptionCommitted term was removed from the numerator as a double-count — the three buckets are already net of it — and the exclusion now lives on the denominator instead, as totalSupply − settledUnclaimedLp. What is still outstanding from it is copy, not math: pools.post.wind-down notification text and the docblock at PlatformPool.sol:762 still describe the old reserve-only formula, and the investor-facing wording call is the open product item already recorded in v3-95 and 10-status-machines.

Implemented (2026-08-03, tranche.post.writedown.ts). All three decisions are in merged code, so the "currently / today" wording above describes the pre-fix handler, not the one running now. D1: the gate is withRole('SUPER_ADMIN', 'ADMIN') — an OPERATOR call is a 403. D2: the wipedOut branch calls proposeImpairmentOnChain and the DB write carries impairment_proposed_at (plus junior_depleted_at, migration 0116, so the operator banner can say why a proposal appeared) and no lifecycle_status — the label flips at executeImpairment seven days later, and the response returns impairment_proposed + deposits_open_during_timelock so the client can prompt for the manual pause the timelock leaves open. D3 needs no branch of its own: only wipedOut escalates, so a partially hit Senior takes the NAV write alone and stays ACTIVE. Regression cover in lib/shared/tranche/__tests__/loss-waterfall.test.ts pins the subordination + wipedOut boundary, and writedown-governance.test.ts reads the handler as text to keep the role list and the absence of a lifecycle_status write from regressing (neither is a value TypeScript can hold, and both regressed once). Still outstanding from this page: the wind-down copy item below — text, not math.

How the endpoint applies NAV, precisely (recorded here 2026-08-05 — it had only ever been written down on 04-pool-models, which is a concept page and the wrong home for it). POST /tranche-writedown is a sibling of nav-changes.post.propose, not a caller of it: it pre-flight-simulates updateNAV on every affected pool, then writes each one directly via updateNavOnChain, recording a nav_history row with source = 'tranche_waterfall'. It creates no nav_proposals row and does not pass through A4 approve / override — those resolve scheduler-suggested values, which a waterfall is not (same reasoning as D1 above). A decrease still queues the standard 24h on-chain timelock per pool. There is no cross-pool atomicity: N sequential transactions, and a mid-fan-out failure returns 207 with per-pool detail for the operator to reconcile.

Refs: amends v3-50; applies v3-92 to the tranche path; 04-pool-models → Loss Waterfall · 06-writedown-nav · 10-status-machines → Lifecycle transitions · 17-changelog v3-106. Notion "NAV·트랜치 — 후속 결정 & 버그수정 (2026-07-31 코드검수)". Source: JY, 2026-07-31.

v3-105 — A partially-filled cancel must claim first; the funding-date badge reads the database, not the chain ✅ Decided · shipped (deployed 2026-08-04)

v3-105 — Epoch cancel orphan closed; funding-date confirmation is an off-chain fact

📌 Status update 2026-08-05 — ✅ shipped (deployed 2026-08-04).

Both contract items are live: ClaimBeforeCancel (RedemptionLib.sol:626) and previewEpochClaim (PlatformPool.sol:317RedemptionLib.sol:703), deployed to dev 2026-08-04 — new factory 0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90 → new pool implementation 0x27D9948FD2A3aF7b035e99d23440A3A877aef968, built from commit 0abe168. Verified on-chain by selector on that implementation (previewEpochClaim = 9ccd0389).

⚠️ Newly created pools only. poolImplementation is immutable on the factory, so a new implementation means a new factory and existing clones are not upgraded. Every pool created before the deploy (newest: 2026-07-23) still runs the old implementation, and no pool has been created from the new factory yet.

Read every "pending / blocked / required" below as historical — it is all built and deployed. The funding-date provenance columns this card left open are also ✅ applied to dev (0111). Card text preserved as written. Date: 2026-07-31

Closes the 🔴 open item in v3-100 and answers the funding-date question the "예정 / 확정" badge needs. Both were re-verified against merged code before deciding.

Why the orphan happens, and why it is the ordinary path. executeEpoch moves the filled gross out of the liquidity buckets into redemptionCommitted but burns no LP — the burn and the payout both happen at claim (_settleEpochClaim). cancelRedemption returns ep.lpRemaining, which is still the full position, and sets a status that claimRedemption rejects. _payCommitted is the only thing that decrements redemptionCommitted, so the reservation is left with no claimant, permanently. And this is not an edge case: cancel is window-gated (C10), settlement happens at the funding date which is after the cutoff, so the only cancel a partially-filled investor can perform is one that follows a settlement they have not claimed.

Who actually loses. Not the investor — they walk away with LP worth their whole position. The pool does, twice over: the cash already left reserveBalance / heldFundReleases / the top-up at settlement, and an inflated redemptionCommitted is permanently subtracted from the wind-down numerator (so remaining holders are under-paid by exactly that amount) and permanently tightens the hold-back release clamp (so that much can never be released to the partner either).

Decision — cancel refuses while anything is claimable. The epoch branch of cancelRedemption computes _epochFillMath and reverts ClaimBeforeCancel(requestId) when filledLp > 0. No new state, no new per-request accounting. UX is two transactions — claim, then cancel — and that is always available: claimRedemption carries no window gate, so the first step works outside the request window. A request that has already claimed reads filledLp == 0 until the next settlement (the carry span ends where it begins), so the common case is a single unimpeded cancel.

Rejected — return only the unfilled remainder and leave the filled slice claimable. Not implementable in this shape. _epochFillMath derives the filled slice as principalLp × epochNewFillRatio[vintage] plus a carry span, so shrinking principalLp to the filled amount re-applies the vintage ratio to a figure that is already net of it. Correcting that means re-anchoring the request to pure-carry form against the latest settled boundary — which is precisely what _settleEpochClaim already does. There is no version of this option that does not settle the filled slice first, so it collapses into the decision above with extra state.

Rejected — auto-claim inside cancel. One transaction, and tempting for exactly that reason, but claim gates on canRedeem and requestHeld. Folding it in means a KYC-revoked or held investor can no longer cancel at all, and cancel is deliberately un-gated so that no one is trapped holding LP they cannot release. An explicit ClaimBeforeCancel revert also tells the investor what to do; a swallowed KYC failure inside a cancel does not.

Decision — previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD) is a required view, and it blocks the FE. The front end cannot compute the split from public state: epochRequests returns only (epochId, principalLp, lpRemaining, yieldAccSnapshot) — no gBase / hBase / generation — and epochH, epochCarryGen and generationCloseH have no getters (only epochG does). Without this view there is no "filled 60 / rolling over 40" display, no honest cancel button copy, and no way to tell an investor why the claim-first gate fired. _epochFillMath is already view, so this is a wrapper.

Decision — the "확정 / 예정" badge reads the database, not the chain. No on-chain confirmation flag will be added. Two reasons, and the first is the substantive one: a stored on-chain date does not mean a confirmed one. Settlement materializes the fail-open date into the very same slot (epochFundingDate[id+1], no event), and cycle 1 is backfilled from fundingAnchor, so a raw-storage getter would report "confirmed" for dates nobody confirmed. A real flag therefore means new storage plus a new view, and pools are EIP-1167 clones with an immutable implementation — so it would cover new pools only. The front end already reads pools.next_funding_date; provenance belongs beside it.

Prerequisite — wire the admin confirm action to setEpochFundingDate. Nothing in apps/infra calls it (zero references under lib/shared/contract/); the admin edit path writes pools.next_funding_date and stops there. So every on-chain cycle date is currently a fail-open derivation, and a truthful badge would read "예정" forever. The confirm action has to reach the chain before the badge means anything — which also gives the SEMI_AUTO reminder (pools.scheduler.epoch-funding-date) something to actually be a reminder for. For audit and reconciliation, EpochFundingDateSet is the only exact confirmation signal on-chain (settlement's materialization is silent); it is declared in GovernanceLib and therefore absent from PlatformPool.abi.json, but the log is emitted under the pool address, so adding the ABI fragment recovers it retroactively for already-deployed pools with no contract change.

Premise carried from the FE plan: the 7 legacy (unanchored) epoch pools are deleted before launch, so the front end handles Model B only and the new view being new-pools-only is acceptable. If that premise changes, the fundingAnchor == 0 carve-out in v3-100 comes back into scope.

🔴 Open — no admin unwind for an epoch request. rejectRedemption accepts REQUESTED / PENDING_RESERVE only, so QUEUED / PARTIALLY_FILLED cannot be rejected at all (v3-100 item 2). With the claim-first gate in place this gets sharper: a KYC-revoked epoch investor can neither claim (blocked by canRedeem) nor cancel (blocked by the new gate), and no admin action can close the request. Needs either an admin-side unwind that releases the reservation, or an explicit carve-out in the gate. P1.

Refs: closes the open item in v3-100; amends v3-93 C10 and v3-91; 07-redemption → Cancellation · 08a-contract-reference · 17-changelog v3-105. Notion "Epoch 재설계 — 기획 확정 & FE 작업" §5. Source: JY, 2026-07-31.

v3-104 — A due-but-unrecorded yield period is a read-model row, not a front-end join: the admin Yield list gets include_due ✅ Implemented (BE + FE, 2026-08-03) · migration 0113

v3-104 — Yield Pending queue moves into the read model

Date: 2026-07-31

What the Pending tab actually shows. A yield_distributions row is created only when an operator or FM records a gross (POST /yield-distributions). Before that the obligation exists only as a schedule on poolsnext_yield_due / yield_overdue / yield_frequency, maintained by the daily pools.scheduler.yield-due sweep. There is no scheduled-period table (the four yield tables are yield_distributions, yield_distribution_investors, yield_claims, yield_funding_events — all records of things that already happened). So the admin Yield "Pending" tab lists pools with no row, which is why it could not read the same source as the other four tabs.

What it does today (the problem). apps/admin-web/app/routes/yield.tsx joins GET /pools (yield_overdue) against GET /yield-distributions?status=PENDING in the browser, splits the result into "needs recording" vs "recorded but stalled", and computes the money itself:

  • estimateExpectedGrosstvl × apy/100 × periodDays/365. A formula the backend never specified: the v3-20 yield-due spec put the estimated amount explicitly out of scope, so the front end invented one.
  • computePoolNet / YieldCalcBreakdown — a second copy of the fee split (platform take, SPC/pool mgmt on AUM × rate × period, perf over hurdle) mirroring computeYieldFees. A fee-policy change in the backend leaves these numbers silently stale.
  • Three different "pending" counts on one screen: the tab badge counts pools needing a record, the table shows those plus the stalled ones, and the Overview panel's Pending counts PENDING records.
  • Sort, search and paging are client-side and capped at the limit: 200 pending fetch; CSV Export on that tab exports records, not the queue on screen.

Decision — extend the read model (Option B). GET /yield-distributions takes include_due=true and returns real records plus synthesized due rows in one payload, discriminated by source: 'RECORD' | 'DUE'. A DUE row carries the pool, the period label it will be filed under, next_yield_due, the estimated gross and the estimated net (server-computed, from the same fee code as a real distribution), and no id. Server-side sort/search/paging and the same role scoping as every other tab. The admin front end then reads one source for all five tabs and keeps display only.

Rejected — Option A: materialize a DUE row when the period comes due. It is the literal "one table", and that is the problem: it puts rows carrying no money into the ledger. Every aggregate over yield_distributions (platform_stats total yield), the investor-facing lists, the indexer's tx_hash reconcile and the /{id}/distribute guard would each need to exclude the new status, and a single missed exclusion overstates what was paid to holders. The ledger records facts; a list that mixes facts with obligations is a read model.

Also decided: the estimate formula and the fee split are backend-owned once this lands (one implementation, reused by the estimate and the real settlement), and the period label authority is the backend — the front end's defaultPeriodLabel prefill stays a prefill.

Estimate basis = APY on TVL, estimate_basis: 'APY_ON_TVL' on the row so a later change is visible: per_period = tvl × apy_rate/100 × periodDays/365. Anchoring on the previous period's actual gross_amount was considered and rejected — it is empty for a pool's first distribution and stale once TVL moves.

One row per pool, with the missed count on it. A pool has at most one open period (the daily sweep does not roll a missed next_yield_due forward), so a 4-months-late pool is one row carrying missed_periods: 4 — and its estimate covers all missed periods (per_period × missed_periods), because a one-period figure on a 4-months-late pool understates what is owed by 4×. Dedupe key is pool_id; a pool that already has a PENDING/PROCESSING record emits no due row.

The PENDING orphan, found while speccing this — resolved by deleting the producer, NOT by a sweeper (revised 2026-08-03). Only two writers insert into yield_distributions: the create endpoint and the indexer. The FM path inserts PROCESSING and stops for /{id}/distribute (so the real "recorded but not yet distributed" state is PROCESSING, not PENDING); the legacy server path inserted PENDING and settled inside the same invocation, making PENDING a sub-second insert state; the indexer only writes DISTRIBUTED. So a row sitting in PENDING was a crash orphan between the insert and settleYield, and nothing healed it: the indexer reconciles on tx_hash, which that row does not have yet, and yield.scheduler.reconcile only mirrors claimable_yield.

The original plan was a sweeper (aged PENDING → read on-chain → heal to DISTRIBUTED or mark FAILED, never settle from the cron, notify on every transition), as handoff Ticket 2. That is superseded. The frequency argument turned out to be the whole answer: the only writer that ever inserted PENDING was the legacy server-key route, and it is unreachable in product — the admin UI refuses it for any pool with a fund_wallet (fmSignBlocked), fund_wallet is required at create for anything that is not display-only (v3-26), and a display-only pool has no pool_address to distribute from, so the handler 400s first. A sweeper would have been permanent machinery maintaining a status that only a dead code path could create.

So the path was deleted instead. POST /yield-distributions now 400s without deposit_tx_hash — FM-signed depositYield is the only way to record a distribution — and migration 0113 makes the status unrepresentable: yield_distributions.status defaults to PROCESSING (it defaulted to PENDING, so an INSERT that merely omitted the column would have reintroduced the orphan silently) plus CHECK (status <> 'PENDING'). One-off backfill in the same migration: a PENDING row with no tx of any kind → FAILED / ORPHANED_LEGACY_PATH; a PENDING row with a tx_hash/deposit_tx_hash raises and aborts the migration, because it may have settled on-chain and marking it FAILED would record a paid period as unpaid — that is not a call SQL gets to make.

⚠️ The yield_status enum value is not dropped: the type is shared with yield_distribution_investors.status, where PENDING is legitimate (an allocation not yet claimed). The ban is a table-level CHECK.

Cost of this choice: server-key yield distribution is given up permanently — a pool operating model where the backend key holds on-chain depositYield permission cannot be used again without reversing this. Acceptable because v3-26 already requires non-custodial fund_wallet pools; reversing it would be a contract-permission decision, not a code change.

What still needs a human, and where it surfaces. Deleting the legacy path leaves one real failure mode: a PROCESSING row whose deposit landed but whose /distribute never ran — the yield is in the pool and holders have not been paid. It has a recovery path already (POST /{id}/distribute + the row action in the admin table); what was missing was anyone noticing. So: dashboard_alert_counts.pending_yield is renamed stalled_yield and repointed to PROCESSING older than 24h (a permanently-quiet alert reads as a working safety net, which is worse than a wrong one), the admin dashboard alert becomes "Stalled Distributions", and the Yield screen's Failed tab lists them under Stalled. The ADMIN-audience notification for the same transition is not built — blocked on copy: notifications/catalog/ is a mirror of the product sheet ("Do not invent wording here", v3-103), so it needs a sheet entry (proposed yield_distribution_stalled) before a producer can exist.

Where the stuck rows live (FE). Out of the Pending tab — that tab is the queue of periods to record, and its badge now equals its rows — and into Failed, the attention tab: FAILED records plus a Stalled table. Originally that table listed the PENDING orphans and was to disappear when the sweeper shipped; since PENDING is now impossible, it lists stalled PROCESSING instead and keeps the table's own row action (Distribute where a deposit_tx_hash exists to verify, disabled with the reason where it does not). The Overview breakdown's Pending row is likewise Stalled.

✅ Implemented 2026-08-03 (BE + FE). BE: include_due=true on GET /yield-distributions with source/due_at/overdue_days/missed_periods/estimated_gross/estimated_net/estimate_basis, FM fund scoping applied to due rows, due_at sort column, X-Total-Count spanning both row kinds. New lib/shared/yield/due-row-math.ts (pure: estimate + row shape + merged ordering, 21 unit tests) and due-rows.ts (the query); computeYieldFees extracted to lib/shared/yield/fees.ts so a read endpoint can reuse the one fee implementation without pulling in Supabase/viem/notify. FE: estimateExpectedGross, computePoolNet and every amount computed inside YieldCalcBreakdown deleted; the pools/records join replaced by useYieldDuePage; the Pending tab moved to manualPagination; its CSV export now exports the queue on screen. Known reduction: there is no live net preview for a gross the operator types — the server can only price the period it knows is due, and pricing an arbitrary gross would need either a second implementation (the defect this entry removes) or a preview endpoint (not built). The FM signs depositYield with the gross, so the missing net does not gate the decision; the final net is on the receipt.

Deliberate deviations from the handoff spec, both documented at the call sites: (1) the new response fields are attached only when include_due=true — the spec's §3 said "every row" but its §5 required a byte-identical response without the flag, and the regression guard is the safer reading; (2) due rows are suppressed by an open PENDING or PROCESSING record rather than PROCESSING alone, so no double-count exists in the window where the Lambda is deployed before migration 0113.

Known limit: a merged page (include_due with no status) is ordered in memory over a record prefix capped at 1000 rows, so a very deep page of the merged list can omit a record that sorts inside it. The only consumer that asks for due rows is the Pending queue, whose record side is empty by construction. Exact paging would need a SQL UNION view.

Refs: 13-operations → Yield Overdue Tracking · 15-api-reference · 11-db-schema → next_yield_due · 05-investment-lifecycle · extends the date-only scope of v3-20 · 17-changelog v3-104. Source: JY, 2026-07-31.

v3-103 — Notification copy SoT is the code, not the sheet; the registry is reconciled at 56 keys; delivery is still stopped at the send worker ✅ Decided · 🔴 F1 delivery blocked (BE)

v3-103 — Copy SoT + registry reconciliation

Date: 2026-07-31

Context. The notification registry (copy.ts) and the Notification Copy sheet had been co-edited by hand and drifted in both directions — the sheet held rows no producer answered to, the code held events the sheet had dropped, and 12 events had text differences with no rule for which one won. Meanwhile the docs still described the 2026-07-28 audit: eight producers bypassing the registry, freeze milestones unimplemented, investor preferences unenforced. All three of those were fixed in the ch/product work and none of it had reached the pages.

Decision 1 — the code is the source of truth for notification copy. Where copy.ts and the sheet disagree, adopt the code. Every real difference in the audit favoured the code (a documented reason or a bug fix behind each one), and the code is the thing that actually renders. The sheet stays as the product-facing view, and the durable fix is to generate it from the registry rather than maintain both by hand — a hand-synced mirror will drift again, which is the whole reason this entry exists.

Decision 2 — three copy calls that were pending.

  1. holdback_release_deferred is critical, not optional (severity info, category REDEMPTION). It is the only message that explains a money discrepancy the FM can otherwise only read as an error, so it must not be disable-able.
  2. fund_member_changed keeps two perspectives: the email is second-person to the member whose access changed, the in-app summary is third-person ({memberName} is now {role} on {fundName}). An ADMIN row is a shared ops feed under Model A, not a personal inbox — this is correct, not an inconsistency.
  3. redemption_rejected must not render {reason}. The operator's mandatory note is written for a later reviewer and can carry AML or verification detail; putting it in the investor's notice is both a PII leak and a tipping-off risk. The sheet row still carries it and must be corrected.

Decision 3 — operator scoping stays deferred, explicitly. recipient_type has no OPERATOR; operators read the ADMIN feed. Splitting them means a new enum value, a per-event audience decision across all 32 admin-app keys, and a second feed scope — and no operator-only event exists yet to pay for it. Recorded so the shared feed reads as a decision rather than an oversight.

Decision 4 — the no-em-dash rule applies to notification copy. The front-end anti-AI-tell guideline forbids in user-facing strings, and the newer registry entries had been written with it. The rule now covers copy.ts too: notification copy is user-facing text that leaves the platform, so exempting it would have meant the product's most-read strings were the only ones allowed to sound machine-written. Replacement is a spaced hyphen. The sheet is already converted; the code sweep over every registry entry is a BE task (dev handoff §B3), not a copy decision — the decision here is only that it applies.

Registry reconciled at 56 keys (verified against producers 2026-07-31, build guard clean). What changed since the 07-28 snapshot the docs described:

  • All eight registry-bypassing producers now route through queueNotification — they had written notification_logs directly, so payload was null and the in-app card rendered one long sentence as its title with no body, no CTA and a SYSTEM miscategorisation. The epoch-execute admin alerts also dedupe per (pool, event_type) per day; an hourly cron over sticky states had been mailing every admin 24× a day.
  • The freeze auto-expiry sweep exists, so freeze_exit_window_open and freeze_expired are built — closing the live defect where an expired freeze kept deposits locked out (v3-97 shipped).
  • freeze_extended is gone, along with the on-chain extension it reported: the extension's 7-day timelock equalled the 7-day freeze lifetime, so it could only fire after the freeze had lapsed or retroactively restart the 72h exit block.
  • impairment_executed / winddown_executed have real producers and send their authored copy (which alone states the lockup lift and penalty waiver) instead of a sentence hardcoded in the endpoint; _ops variants reach FM and Admin, who previously got nothing. createSystemPoolUpdate no longer hardcodes priority: 'optional'MATERIAL_EVENT is forced critical.
  • New: redemption_exit_gate_blocked (v3-99), epoch_funding_date_due, holdback_release_deferred (v3-100), pool_lifecycle_active.
  • Preference namespaces unified on event_key for both audiences (0101 admin / 0108 investor), and investor enforcement is live (2026-07-30). pool_lifecycle_active is the first optional investor event — before it the togglable set was empty, so there was nothing to enforce. Its recipient list is an explicit opt-in (pool_follows, 0109) because its copy says "you asked to be notified", which is false of anyone who did not.
  • INVALID_RECIPIENT excluded from the failed-notification KPI (0107) and whole-audience opt-outs marked SUPPRESSED rather than FAILED (0100) — both so the ops queue can reach zero and stay readable.
  • Two guards now hold this in place: check-notification-producers.mjs (copy↔producer pairing, exact quoted-literal matching) and check-copy.mjs extended over infra.

🔴 Open — F1: nothing is actually delivered. The send worker is not consuming SENDING rows; the last DELIVERED row is 2026-07-03. The EventBridge rule exists in api-stack.ts, so the first question is which commit is deployed — if the runtime is still on the old V1, this is a stale deploy rather than a broken worker. The backlog hazard is cleared (1139 → 8 rows, 1005 duplicate epoch_execute_failed moved to SUPPRESSED by hand), so the drain can be enabled without a storm. BE/ops.

Schema docs re-synced with it (CLAUDE.md rule 6). 11-db-schema had been verified through 0107 and was therefore missing both notification migrations: 0108 (investor preferences re-keyed on event_key — the change that makes Decision-adjacent enforcement possible) and 0109 (pool_follows, +1 → 47 tables · 26 enums). The page's own two count lines had also drifted apart — the footer still claimed 40 tables / migration 0050 — so both now read 47/26 through 0109, and the _(pending dev apply)_ markers on 0100–0107 are cleared: that batch went to dev on 2026-07-30.

Also open: delete redemption_funded + lp_mint_failed from the registry (the sheet already dropped both) · the em-dash sweep over copy.ts (dev handoff §B3) · epoch-execute still selects a MATURED pool into its candidate set (no lifecycle_status filter) and alerts a benign state as a failure, with skip-vs-alert undecided · LEAD_DAYS 3 → 7 on epoch_funding_date_due (v3-100) · sheet rows for redemption_exit_gate_blocked and epoch_funding_date_due · epoch_demand_finalized and over_funding_detected are unblocked by the epoch merge but have no registry entry at all, so the producer guard does not cover them — registry entry first · redemption_rejected / redemption_returned_unfunded strings pending legal sign-off (Notion "Legal Review Required" #25 — branching, vars and failure_type routing are final; only the strings may change).

Refs: 22-notifications (rewritten against the registry) · 13-operations → Notification rules (per-event matrix removed) · 11-db-schema (0108/0109, counts re-synced) · closes the doc item in v3-97; ships v3-99 and v3-100 notification work; CLAUDE.md rules 2-b + 6. 17-changelog v3-103. Notion "Notification System" handoff §6. Source: JY, 2026-07-31.

v3-102 — distributeYield + withdrawFees become one settleYield: the half-settled period is now unrepresentable ✅ Decided · shipped

v3-102 — settleYield: one tx per yield period

Date: 2026-07-31

The failure this removes. runYieldDistribution called distributeYield(net) and then withdrawFees(treasury, poolMgmt) — and .catch()'d the second into a console line. So a failing fee leg produced: holders credited, fee not collected, the row finalized DISTRIBUTED with fee_amount / pool_mgmt_fee_amount recorded as if it had been taken, and no recovery — POST /yield-distributions/{id}/distribute returns early on a DISTRIBUTED row and 400s on anything that is not PROCESSING, and withdrawFees had exactly one caller. Only a direct contract call from outside the product could collect it. The money is not lost (it stays in unclaimedYield), but the DB asserts a payment that never happened, and the residue permanently inflates unclaimedYield, which shrinks what _releaseHeldFunds will release to the partner. Realistic triggers were a pool with pool_mgmt_bps but no fund_fee_wallet (FundFeeWalletNotSet — an instant, un-timelocked setter, so an easy config gap) and ordinary RPC/tx failure.

Decision: merge them. settleYield(address stablecoin, uint256 netAmount, uint256 treasuryAmount, uint256 poolMgmtAmount) replaces both; distributeYield and withdrawFees are removed, so there is one way to settle a period and no second entry point to wire wrongly. Three things fall out:

  1. No half-state. Either the period settles or nothing happened on-chain. The backend's failure branch collapses to one.
  2. One conservation check. netAmount + normalize(treasury + poolMgmt) ≤ unclaimedYield, evaluated before any state moves. Previously each leg was bounded separately, so net could consume the whole period and the fee call then revert. This also promotes an invariant 09a-custody had recorded as a Lambda call-ordering convention (distributeYield before withdrawFees) into an on-chain bound.
  3. Smaller. Two pool wrappers became one: PlatformPool 21,064 → 20,929 B (EIP-170 margin 3,512 → 3,647).

YieldDistributed and FeesWithdrawn keep their signatures, so the indexer is untouched. When no LP is eligible the net stays in unclaimedYield (unchanged behaviour) and the fee legs still pay — they are independent of whether anyone can be credited.

The signature is deliberately mixed-axis, and that is the rule, not an exception. netAmount is NORMALIZED (18) because it only moves the ledger; treasuryAmount / poolMgmtAmount are RAW because each wraps a safeTransfer. That is exactly v3-101's rule, and the backend's branded types make the two non-interchangeable at compile time — which matters here because, as always on this path, the wrong scale does not revert.

Why a contract change was acceptable at all: pools are immutable EIP-1167 clones behind a factory whose poolImplementation is immutable, so this needs a new implementation, a new factory, and recreating every pool. That is only cheap pre-launch — no distribution has ever run. Deferring it would have meant carrying the swallowed-failure path into production permanently.

The backing invariant gap, also closed. It omitted distributed-but-unclaimed yield. _releaseHeldFunds was nonetheless safe — its release is clamped to heldFundReleases, which is itself inside the invariant — so this was a verification gap, not a leak, and an earlier read of it as a live leak was wrong. The cause was naming: unclaimedYield()'s natspec said "deposited and distributed but not yet claimed by holders" and the Foundry invariant's own docblock said the property existed to protect "distributed-but-unclaimed yield", while the field is decremented at distribution — so the term everyone thought was covered was never in the sum. Distributed yield lives only in the per-user accruedYield mapping and has no aggregate, so the fix is in the harness, not the contract: the invariant now adds Σ pendingYield(investor) over its fixed actor set. Verified non-vacuous (the added term reaches ~49e18 during a run, checked by temporarily asserting it stays zero and watching that fail). Both misleading comments corrected. No new storage — an on-chain distributedUnclaimedYield counter would be redundant with this and is deliberately not added.

A via_ir-only test failure, found and fixed while verifying. PlatformKYCSoulbound.test_RenewExtendsExpiryInPlace failed under the dev and prod profiles and passed under the default one. Cause: solc may treat block.timestamp as constant within a call — true of a real transaction, not of a test that warps — so with via_ir's stronger CSE the second block.timestamp + ONE_YEAR folded into the first and the test renewed to the already-expired date. It was red before this work and unrelated to it, but it meant make test-prod was never green. Deriving the target from expiresAt fixes it; the suite is now 369/369 on both profiles.

Verification: forge test 369/369 on both the dev and prod profiles. 3 new atomicity regressions (fee-leg failure credits nothing · combined draw bounded once · no eligible holder still pays fees), plus the strengthened backing invariant. ABI regenerated (263 → 262 entries). infra tsc clean, 162/162, all four guards clean; both frontends typecheck against the shrunk ABI (neither calls the removed functions on-chain). ⚠️ Not deployed — and note that settleYield does not exist on already-deployed clones, so yield settlement is blocked until a new implementation + factory ship and the pools are recreated.

Refs: 08-smart-contracts → Amount Units; 09a-custody; 05-investment-lifecycle; 23-money-path; 17-changelog v3-102. Follows v3-101. Source: CH, 2026-07-31.

v3-101 — Amount axes are types, not comments; the contract stays as-is; two live 1e12 bugs fixed ✅ Decided · shipped

v3-101 — Raw vs Normalized as a typed boundary

Date: 2026-07-31

Context. runYieldDistribution handed distributeYield a 6-decimal netAmount where the contract expects the 18-decimal ledger axis — 1e12 too small. JY's handoff proposed either fixing the backend or changing the contract so all three yield steps take raw amounts, and asked which. The audit that followed changed the answer.

The contract is already consistent — do not change it. All eight amount-taking entry points obey one rule with zero exceptions: an argument that names tokens moving in this tx is Raw (that stablecoin's decimals); an argument that names a figure on the pool's ledger is Normalized (18). depositYield / withdrawFees / fundRedemption / deposit wrap transfers → Raw. distributeYield / claimYield / reinvest / updateNAV.reserveConsumed / setHardCap / the PoolConfig caps meet ledger counters → Normalized. So the handoff's read of the yield flow — "the middle step is the odd one out, hence a trap" — was a misreading: depositYield and withdrawFees are transfer functions and distributeYield is an accounting function. Unifying on raw would have forced a currency onto a currency-less aggregate (unclaimedYield sums every accepted stablecoin), i.e. broken the rule to make it look uniform. Table in 08-smart-contracts → Amount Units.

Root cause is the backend boundary, not the ABI. A wrong axis does not revert: every on-chain check on these arguments is an upper bound, so a 1e12-too-small value passes, the tx succeeds, and the off-chain records still look right because they are computed separately. The mismatch only surfaces at an investor's claimYield. And the axis was documented — @param netAmount ... (normalized to 18 decimals) in PlatformPool.sol, plus a YIELD_NORMALIZED_DECIMALS = 18 constant in the backend — and the slip happened anyway. Documentation is not a mechanism here.

Decision: brand the axis in TypeScript. RawAmount / NormalizedAmount / NavPrice in lib/shared/contract/units.ts, with the converters as the only way to construct one; every on-chain amount parameter in lib/shared/contract/* now declares its axis, so a wrong-axis value is a compile error. scripts/check-onchain-units.mjs (wired into infra build + test) bans parseUnits outside that module and confines branded casts to lib/shared/contract/. Not covered at the time: the read direction (formatUnits, 12 call sites) — a wrong scale there is a visible display error, not silent chain corruption, and banning it on day one would have meant 12 escape hatches. Closed in v3-102: all 12 were converted and the guard now covers both directions with zero exemptions.

Two live bugs, both found by applying the types, both 1e12 too small and both silent:

  1. distributeYield(netAmount) — the reported one. No live damage: no real distribution had ever run (yield_claims = 0, yield_distribution_investors = 0, the two DISTRIBUTED rows are seed data), so no on-chain accumulatedYieldPerShare is skewed and no migration is needed.
  2. updateNAV(reserveConsumed)not previously reported. nav-changes.post.propose and nav/apply-proposal both parsed it at 6 decimals, but the contract debits it straight out of s.reserveBalance, an 18-decimal counter. A write-down therefore burned ~nothing of the reserve and reported success, leaving the reserve overstated for redemption fills and the wind-down NAV.

YieldDistributed.netAmount had to be re-decoded at 18 in the same change: the indexer read it at 6, which cancelled the bug and made the recorded number look correct. Fixing one side alone breaks the record. yield_per_share was never cancelled and was simply 1e12 low.

Still worth doing on the contract, for a different reason. Merging steps 2+3 into one settleYield is justified by atomicity, not units: run-distribution.ts swallows a withdrawFees failure, so net-distributed-but-fees-uncollected can persist silently and a retry double-distributes. Not a unit fix — decide separately. Same release candidate: distributed-but-unclaimed yield is missing from the backing invariant (no aggregate counter exists for it), so _releaseHeldFunds can release balance backing investors' accruedYield. Both open, neither blocking.

Refs: 08-smart-contracts → Amount Units; apps/infra/lib/shared/contract/units.ts; scripts/check-onchain-units.mjs; 17-changelog v3-101. Supersedes the A/B choice in JY's yield-distribute-scale-mismatch-handoff.md. Source: CH, 2026-07-31.

v3-100 — Epoch engine: the pot became a scalar; v3-93's six open items closed; one live accounting leak found ✅ Decided · 🔴 1 open (CH)

v3-100 — Epoch redesign close-out

Date: 2026-07-30

Context. v3-91 and v3-93 were written before the engine was built and deliberately left six questions open. The ch/product merge then shipped the engine, so those questions are now answerable against code rather than intent — and one of them turned out to be answered the wrong way. Two of the three things recorded here are corrections to our own earlier entries, not new product decisions.

Recorded for the first time — 🔴F, the index collapse. The highest-priority defect of the whole audit had no entry on any docs page, and worse, 07-redemption published the broken formula as the design. G[id] = G[id−1] × (1 − fillRatio) reaches zero on the first full fill, and fillRatio = 1 is the operating goal — so liquidity gating died on the first healthy cycle and the terminal claim branch then paid remaining balance with no liquidity check, first-come. All 27 pre-existing epoch tests passed the entire time. Request-time (gBase, hBase) snapshots are necessary but insufficient: a snapshot of an already-zero index is still zero. Fix = restart the ladder by generation — a full fill ends every outstanding request in that generation, so the ladder can restart at 1e18 instead of collapsing, with generationCloseH[gen] preserving what an unclaimed straggler is owed. demand == 0 is explicitly not a full fill. The same restart replaced a LadderPrecisionExhausted revert that turned out to be reachable in ordinary operation (funding the shortfall exactly, three epochs running) — blocking settlement traps funds, which is worse than discarding sub-wei dust the payout floor already drops.

Two backward-compatibility carve-outs worth knowing before deploy. fundingAnchor == 0 means "no anchored schedule" and skips the window gate entirely, preserving the old lazy behaviour — this is what keeps the 7 already-deployed epoch pools from bricking, and because setEpochSchedule is create-only those pools stay on the legacy path permanently. Model B is therefore a new-pool property, not a platform-wide one. Separately, ep.gBase == 0 falls back to live index reads for requests created before the snapshot existed; there are 2 in-flight epoch requests in live DB, and whether either sits after an old full fill should be checked before deploying rather than after.

Correction 1 — the settlement escrow is a scalar, and it shipped. v3-91 specified a per-epoch epochClaimable[id] pot. That was withdrawn on 2026-07-27 and never propagated into this page, which is the whole reason the 07-redemption status table asked CH whether the double-commit fix had landed: it was looking for a field that had been designed out. A claim spans epochs (H[latest] − H[vintage]), so draining per-epoch pots is O(V) in the number of vintages and destroys the O(1) claim the ladder exists to provide. The shipped mechanism is a single redemptionCommitted scalar: executeEpoch debits the filled gross T[id] → H → R (epoch-specific money first) and adds it to the scalar; epoch claims draw only from the scalar via _payCommitted, never from live reserve. Instant keeps _drawDown. Invariant physicalBalance ≥ reserveBalance + heldFundReleases + redemptionCommitted + unclaimedYield, enforced by Foundry invariant test rather than an on-chain assert (looping stablecoinList on every path is a gas / revert risk). Two follow-ons shipped with it: _debitLiquidity / _debitPrincipal are split, so moving the liquidity debit to settlement does not drag totalDeposited forward and make the gating cap self-referential; and setFundingRestricted(false) clamps its release to physicalBalance − (reserve + committed + unclaimedYield) so lifting the hold-back cannot strip the backing of debt already promised.

Correction 2 — the wind-down numerator (🔴W). executeWindDown priced liquidation off reserveBalance alone, ignoring heldFundReleases and epochFundTopUp — the two buckets that actually hold recalled money — so investors were paid less than the pool had recovered, and the scalar above widens the gap because settlement now empties reserve first. Numerator is reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted; committed debt is excluded because that cash is already owed to named claimants. Shipped, and the request gate had to move with the numerator or a wound-down pool prices an exit it then refuses to settle. ⚠️ Superseded in part by R10 (0abe168, deployed 2026-08-04): the − redemptionCommitted term was removed from the numerator as a double-count — the three buckets are already net of it — and the exclusion now lives on the denominator instead, as totalSupply − settledUnclaimedLp. Detail in 04-pool-models.

Closing v3-93's six open items (decided 2026-07-27, recorded here):

  1. Over-funding leftover → rolls forward to the next epoch, plus an over-funding notice to the FM. The T → H → R debit order consumes most of a top-up already, so only a genuine excess is ever left; no return path needs building.

    🔴 "Rolls forward" is wrong, and corrected by v3-137 (2026-08-17). The bucket is keyed by cycle and a settlement reads only reserveBalance + epochFundTopUp[currentEpochId] (RedemptionLib.sol:955), so cycle n+1 cannot see what cycle n left; and reserveBalance += appears exactly once in the repository (PoolLedgerLib.sol:73), so nothing moves it there either. The leftover is bound to its own cycle — not lost, since it stays in totalEpochTopUp and reaches holders through the wind-down numerator, but not spendable by a later settlement. The second half of the item stands: there is no return path. This sentence is the origin of the error — the over_funding_detected notification body and epoch-redemption.ts's doc comment both cite this decision, and each reproduced it faithfully. ✅ Both are corrected: the comment on 2026-08-17, the notification body plus the test assertion holding it in place on 2026-08-18 (22-notifications).

  2. Admin rejection carries no cutoff gate — as-is. There is nothing to gate: rejectRedemption only accepts REQUESTED / PENDING_RESERVE, which are instant-only states, so epoch requests cannot be rejected at all. If epoch rejection is ever opened, the first thing to fix is that rejectRedemption does not decrement demand.
  3. No separate regulatory forced-cancel path. holdRequest (blocks claim) plus the canRedeem gate at claim already cover KYC revocation and sanctions. ⚠️ Tracked follow-up: a held request still counts toward demand and therefore consumes fill capacity.
  4. demand == 0 epochs → merged into 🔴F, not a separate item. A zero demand reads as a full fill, which is the same failure as the NAV > 0 guard, and the request-time (gBase, hBase) snapshot fix covers it.
  5. Instant PENDING_RESERVE cancel needs no gate. There is no confirmed total to falsify, and partner funding already posted returns to fund_wallet — the money round-trips, nothing leaks.
  6. Pending epoch demand at WIND_DOWN is consistent. Escrowed LP is not burned, so it stays in totalSupply and waiting investors take their pro-rata share like everyone else. The real problem — being trapped by the window gate — is closed by the distress exception (WIND_DOWN / IMPAIRED skip _requireRequestWindow).

🔴 Open — a post-settlement cancel orphans its reserved payout. Found while verifying the above; live in merged code. cancelRedemption returns ep.lpRemaining, and lpRemaining is only reduced at claim. A partially-filled investor who has not claimed therefore gets all escrowed LP back while the filled slice's USD stays in redemptionCommitted with no claimant — permanently subtracted from the wind-down numerator in Correction 2, so remaining holders are under-paid by exactly that amount. The C10 window gate does not prevent this. Item 4 of v3-93 assumed it would; the carry-over exception (a partially-filled investor may cancel in the next window) is precisely the path that reaches it, and the current behaviour is pinned rather than fixed by test_C8_CancelRolledOver_ClearsCarryBucket. The question for CH is whether a cancel should return only the unfilled remainder and leave the filled slice claimable, or release the reservation back to the buckets. Not decided — with CH.

Three smaller handoff calls (2026-07-30):

  • Funding-date reminder lead time 3 days → 7. LEAD_DAYS in pools.scheduler.epoch-funding-date; three days is not enough for a partner to confirm a settlement date. Constant only, no logic change. ✅ Shipped with the notification pipeline rebuild, 2026-07-31.

  • Hold-back release remainder → the FM gets notified (decided). Correction 1's clamp means lifting the hold-back can leave part of the release in the bucket rather than sending it, and the FM has no way to see why: from their side a release simply paid less than expected. So the clamp needs a voice. Trigger = a setFundingRestricted(false) release clamped by settled-but-unclaimed debt; audience = FM, in-app + email; substance = "$X of the release is deferred while it backs pending redemption claims, and pays out once those claims settle." (⚠️ the word automatically was in the decided substance and shipped in the copy; it was removed 2026-07-31 — nothing re-runs the release, so it promised a mechanism that does not exist. See 17-changelog and 23-money-path.) Nothing is lost and no action is required — which is exactly why silence would read as an error. ✅ Shipped as holdback_release_deferred: indexer writer on FundingRestrictedSet(restricted=false), remainder read back pinned to the event's block (the event carries no amount, and _releaseHeldFunds ran inside that transaction), silent when the remainder is 0 or unreadable, re-arming daily. Detail in 22-notifications.

    Building it surfaced a scale bug in four other money notices. The pool normalizes every USD figure to 18 decimals (PoolCommonLib.normalizeAmount), but the indexer decoded several of them as USDC's 6 — overstating by 1e12. Affected: epoch_funding_needed and the funding_shortfall column behind the "Awaiting Funding" KPI; epoch_settlement_complete's payout, which also wrote redemption_requests.payout_amount and every redemption_fills row; redemption_pending_reserve's shortfall; and readEpochShortfall, which divided by LP_PRECISION instead of NAV_PRECISION and so compared a 1e6 demand against an 18-decimal balance — shortfall computed as ~0 on any real pool, meaning the D-2 / D-12h partner-funding reminders never fired at all. Same class as the earlier G4 penalty fix. Named NORMALIZED_DECIMALS so the distinction from raw transfer amounts (Deposited.amount, YieldDeposited.grossAmount) is explicit.

    🔴 One more found in the same sweep, NOT fixed — it changes on-chain behaviour. runYieldDistribution converts netAmount with parseUnits(…, 6) and passes it straight to distributeYield(netAmount), which expects the 18-decimal normalized scale (it subtracts from unclaimedYield and computes yieldPerShare = netAmount × 1e18 / totalLPSupply). depositYield and withdrawFees on either side of it do take raw stablecoin units, which is what makes the middle call easy to get wrong. If confirmed this under-distributes yield by 1e12×, and deployed pools would already carry a skewed accumulatedYieldPerShare — so it needs CH plus a live-state check, not a one-line edit. The indexer's matching YieldDistributed.netAmount decode is deliberately left at 6 decimals: today it cancels out the bug, and flipping it alone would make the recorded figure wrong. The two must move together.

CLOSED 2026-08-20 by removal, not by a fix. v3-131 (3) deleted the netAmount argument and the per-share accumulator with it, so there is no scale left to get wrong at this call site and no YieldDistributed event to decode (it is YieldPeriodSettled(funded, fees, undistributed)). What holders are owed is now derived on-chain from time. The general lesson stands and is recorded at 08 → argument axes: an argument whose only guard is an upper bound cannot be validated, and removing it beats documenting it.

  • Withdrawals blocked beyond 72 h — as-is, no change. v3-28 stands. Per-investor restriction is already covered by holdRequest + the KYC gate; a pool-wide block longer than 72 h would need a demonstrated regulatory case and legal review first.

Refs: amends v3-91 and v3-93; 07-redemption → Epoch-Based Redemption; 04-pool-models → Emergency Wind-Down; 17-changelog v3-100. Notion "Epoch Redemption — 코드 검수 정리" (JY handoff reply, 2026-07-30). Source: JY, 2026-07-30.

v3-99 — Redemption exit gates; Return position replaces "reject", ADMIN-only, reason required ✅ Decided · shipped

v3-99 — Exit gates + `Return position` naming

Date: 2026-07-30

Context. Nothing documented what can stop a withdrawal, so the rules could only be recovered by reading the contract. That produced two wrong beliefs worth naming: that epoch pools have no AML gate (they do), and that the operator's reject button is the AML enforcement mechanism (it is not).

Decision — three distinct gates, documented in 07-redemption:

  • Lockup / maturity — automatic, 3-state model.
  • Verification — automatic and on-chain, via canRedeem, checked at both request time and payout time. REVOKED blocks the exit; EXPIRED still passes (v3-31) — blocking on expiry would trap an investor's own capital behind a lapsed document, the failure mode v3-28 exists to prevent. Applies identically to epoch pools, which is why epoch needs no per-request operator decision.
  • Operator action — instant pools only, REQUESTED / PENDING_RESERVE only.

Decision — the CTA is Return position, one button, reason mandatory. "Reject" describes denying an entitlement; the action returns the escrowed LP and leaves the holder with their position, so it is a reversal, not a denial. Release and Freeze were unavailable (already hold/release and pool freeze). A second Block for compliance button was considered and dropped: its main justification was splitting permissions, and that disappeared once the action became ADMIN-only. The remaining differences — investor wording and reporting bucket — ride on the reason instead, which the system pre-selects (compliance when the periodic check flagged verification, unfunded for a stalled shortfall) and the operator can change, plus a mandatory free-text note. Same propose-then-confirm shape as NAV approve/override. DB REJECTED is unchanged — the split is carried on failure_type, which also fixes voluntary cancellations inflating the rejection rate.

Decision — ADMIN / SUPER_ADMIN only; FM is excluded. Not a tidy-up: the action's main use is closing a shortfall the fund never funded, and the FM is that fund. Leaving it with them means the party that owes the money can end the investor's exit request — the LP returns, but the exit stays shut, which repeated is a refusal in effect. This resolves a three-way conflict in favour of the Notion FM-panel PRD, which had said FM has no such gate while the code and this page allowed it; 09-rbac and the handlers move to match. Consistent with v3-04 removing FM_ACCEPTED on the principle that the FM is not an approver. The FM keeps the shortfall alert, read access, and signing fundRedemption from its own wallet.

The gap this exposed, and the fix — ✅ shipped 2026-07-30. _executeRedemptionPayout has three callers; only claimRedemptionFallback checked canRedeem first. approveRedemption and the partner-funding auto-settle inside fundRedemption called it directly, so a request that passed at request time and waited in PENDING_RESERVE while the SBT was revoked was paid when funding landed — the outcome depended on which path settled. (fundRedemption's checkExitNotBlocked is the freeze gate, not this one.)

  • Checked inside _executeRedemptionPayout, not per caller — one check covers all three paths, and per-caller patching guarantees the next caller misses it, which is exactly how this gap appeared. The fallback's own pre-check is left in place: redundant but harmless, and it carries the policy comment.
  • Reverts RedemptionBlockedByKyc; does not auto-transition the request. Reverting does not strand the partner's money — the transfer and the payout are the same transaction, so it rolls back too. (Split funding can leave an earlier top-up inside, but that predates this change and Return position already returns it to fund_wallet.) Auto-transitioning was rejected because it would let the partner's funding transaction execute Aset's compliance decision, recording the partner as the actor.
  • Paired with an hourly sweep, not with watching for the failed transaction. A revert is silent to Aset, so a sweep over PENDING_RESERVE requests alerts an admin when a holder no longer passes. Watching the partner's reverted tx would miss cases and only fire after they had already spent gas. The sweep changes no state — closing the request stays an explicit admin action — and it is deliberately not fail-closed on an unreadable chain: there is no payout and no human awaiting a response, and alerting on every RPC hiccup would train admins to skip the alert.
  • Still open: the sweep's finding is recorded against the request but the admin dialog does not read it yet, so the COMPLIANCE pre-select above is not wired — the category still derives from request state alone.

Also corrected here: 09-rbac granted FM "approve/reject redemptions" and listed both in the role matrix. v3-82 removed manual approve for every role (reserve-covered settles on request, a shortfall settles when funding arrives), so Return position is the only redemption decision left in the panel, and this decision reserves it for ADMIN / SUPER_ADMIN. The role-matrix row was renamed and FM access removed to match.

Refs: 07-redemption → Exit gates; 09-rbac; v3-31, v3-28, v3-82, v3-84. Notion "상환 출금 게이트 & Reject 재정비" handoff. Source: JY, 2026-07-30.

v3-98 — KYB (institution onboarding) dropped: the platform onboards individuals only ✅ Decided

v3-98 — KYB out of scope

Date: 2026-07-29

Decision: the platform onboards individuals only. KYB (legal-entity onboarding) is dropped — not deferred. No target date, and the August 2026 SumSub Enterprise KYB subscription that v3-75's policy depended on is not being taken. v3-75 is superseded: its jurisdiction research is retained in 03-kyc-identity as reference, not as a roadmap.

Why this needs its own entry rather than a status tweak. "Deferred" and "dropped" read the same in a table and diverge completely in practice. Under deferred, every INSTITUTION branch is scaffolding worth keeping warm and "coming soon" is honest. Under dropped, the same branches are dead paths and the same copy is a promise the product will not keep. The docs had drifted to the first reading — 03-kyc-identity said "not usable yet … planned August 2026 … KYB is a separate workstream, which ships first", which is exactly how a reader concludes entity onboarding is on the way.

What stays, deliberately:

  • kyc_level enum (INDIVIDUAL/INSTITUTION) + SUMSUB_LEVEL_MAP — the SBT stores a level and v3-63 (migration 0063) already removed level-based pool gating, so the dormant INSTITUTION value costs nothing and ripping it out would touch the SBT contract. Its presence is not evidence the feature exists — that inference is the specific mistake this entry exists to block.
  • users.company_name / company_country — written by the SumSub webhook; company_country still feeds the jurisdiction derivation for any legacy INSTITUTION row.

🔴 Open — investor-facing copy (the one item that must change). KYCModal renders "Institutional verification is coming soon" behind the VITE_KYB_ENABLED gate. With KYB dropped that sentence is a commitment the product will not honour, which is the failure mode CLAUDE.md rule 2-b was written to prevent. Two options: (a) reword to state entity accounts are not supported, or (b) remove the individual/institution selector so the choice never appears. Not yet decided.

Not in scope of this decision: the Reg S qualified-investor model (v3-74) is unaffected — it was always individual-first and ships independently. The "large-undertaking 2-of-3" entity test in v3-75 becomes moot only while KYB is out.

Refs: supersedes v3-75; 03-kyc-identity → KYB; v3-74 unaffected; 17-changelog v3-98. Notion "KYB(법인 온보딩) — 현황 및 활성화 계획" (단계 = 드롭). Source: JY, 2026-07-29.

v3-97 — Freeze milestones get notified: dates up front and two resumption notices; dedupe keyed to the freeze cycle 🔧 Decided · BE unimplemented (CH)

v3-97 — Freeze notification policy

Date: 2026-07-28

Context. Freeze is the only status whose milestones pass with no transaction and no event. Under the v3-28 asymmetry, value-out reopens 72 h after freeze_started_at and the whole freeze lapses at 7 days — both computed on read, neither emitted. The contract announces nothing, so the platform is the only possible source of the notice, and today it sends none: pool_unfrozen is queued only from the EmergencyUnfrozen event, which auto-expiry never fires. Meanwhile pool_frozen told investors "we will notify you as soon as activity resumes" — a promise with no producer behind it, and it also said deposits and withdrawals are paused without mentioning that withdrawals reopen at 72 h. This is exactly the surface v3-96 flagged as highest-consequence-unaudited.

Decision — dates up front, plus two resumption notices. Not either/or.

  1. pool_frozen states the two times it already knows. Freeze start fixes both milestones, so the copy carries withdrawalsResumeAt + haltLiftsBy instead of promising a future message. Removes the unkept promise and the "withdrawals are paused, full stop" error in one edit. The FE banner already did this in v3-95; email was never brought in line. freeze_extended gets the same treatment (it must say when withdrawals reopen, because an extension restarts the 72 h block).
  2. freeze_exit_window_open at +72 h (new). Stating an expected time up front does not discharge the duty to confirm resumption — industry practice for suspended redemptions treats notifying investors when redemptions resume as its own obligation, and 72 h is the moment an investor can act on their money. Highest-value single notice in the set.
  3. freeze_expired at +7 d (new). haltLiftsBy in the freeze notice is an upper bound, not a confirmation; auto-expiry leaves no trace an investor could check. Kept distinct from pool_unfrozen deliberately: auto-expiry is the design working, not an operator judgement, so the copy reads "lifted automatically" and the log keeps the two provenances apart (an investor who reads "unfrozen" asks who decided that, and why).
  4. Dedupe is keyed to the freeze cycle: (event_type, related_entity_id = pool, created_at >= freeze_started_at), the partner-funding sweep pattern. Not queueNotification's dedupByEntity — that is one-per-pool-forever and would silence every freeze after the first. Because an extension re-bases freeze_started_at, this key re-arms both notices automatically, which is the correct behaviour rather than a side effect.
  5. Freeze copy needs a datetime formatter with an explicit UTC label. formatDate() truncates to YYYY-MM-DD, which cannot express a 72 h milestone — an investor reading a bare date tries at 09:00 for a window that opens at 02:00. Email has no browser, so it cannot localise the way the FE's milestoneLabel() does.

Rejected: channel split (72 h in-app only, expiry by email). queueNotification hardcodes channel: 'EMAIL' and the send worker takes every SENDING row, so in-app-only needs channel-filter infrastructure — and it would route the most consequential notice through the weakest channel.

Sequencing. Both new events depend on the auto-expiry DB fix: is_emergency_frozen never clears, so nothing today knows the 7-day mark passed. The milestone sweep and these two notices ship together. Note the sweep is therefore broader than "clear expired flags" — it must also inspect pools that are still frozen for the 72 h mark. Sweep cadence sets the notice lag (hourly on pools.scheduler.lifecycle → ≤1 h).

Also found while specifying this: freeze_extended interpolates the new freeze start into a row labelled Until (indexer/writers/freeze.ts), so extension emails currently show today's date as the end of the freeze. Real value is start + FREEZE_MAX_DURATION (7 days, a constant).

Refs: 22-notifications → Freeze notifications; acts on the open item in v3-96; v3-28 (freeze asymmetry); v3-95 (FE banner precedent); 10-status-machines; 17-changelog v3-97. Notion "Freeze auto-expiry" BE handoff. Source: JY, 2026-07-28.

A4 — NAV propose/approve/override (automated suggestion uses ABSOLUTE recompute) ✅ Decided

A4 — NAV suggestion formula + suggest/approve/override layer

Date: 2026-07-27

Context: Admins manually typed each new NAV. A4 adds an automated-suggestion + human-approval layer: a nav-proposals.scheduler.suggest sweep (~12h) proposes a NAV, and an ADMIN/SUPER_ADMIN approves or overrides it into the existing propose path. Proposals live in a new nav_proposals table decoupled from nav_history (state model B).

Decision — the suggestion is an ABSOLUTE recompute, not the doc's multiplicative chain. For a standalone pool (tranche_group_id NULL) from its latest external_pool_data_snapshots row + the live reserve:

lossRatio        = cumulative_loss ÷ total_subscribed       ← same partner report, so currency cancels (R7)
cumulativeLoss   = lossRatio × total_deposited              ← now in the pool's currency
bufferCap        = total_deposited × buffer_rate_bps / 10000   ← 0 when buffer_basis = NET (R6)
uncoveredLoss    = FIRST_LOSS ? max(0, cumulativeLoss − bufferCap)
                              : min(max(0, cumulativeLoss), bufferCap)   ← buffer, NOT reserve (R8)
rawNav           = (net_principal − uncoveredLoss) / lp_total_supply       ← on-chain LP supply (R9)
suggestedNav     = clamp(rawNav, 0.000001, 1.0)

✅ Formula amended by v3-109 — shipped in nav-suggest.ts. The box above is the canonical formula. The arithmetic now lives in a pure, tested nav-formula.ts and the resolver only fetches its inputs. Three amendments landed:

  • R8 removed the availableReserve term — the reserve is investor money already inside the claim the numerator prices.
  • R6 made the buffer real configuration (buffer_rate_bps / buffer_direction / buffer_basis, all defaulted so the arithmetic is unchanged at rate 0).
  • R7 turned the partner's foreign-currency loss into a dimensionless ratio before it touches the pool's USD book — without which a healthy EFIDR pool computed to NAV −1782 and escalated as a total loss.

R9 landed too — the denominator is pools.lp_total_supply (0117). The concern that a DB numerator over a chain denominator turns an indexer stall into a NAV jump is handled by refusing to price rather than by falling back: usableTotalSupply returns null for NULL (never mirrored) and for 0 with principal in the pool (money and no tokens — real on dev), and the sweep skips the pool. Callers must not substitute net_principal; that substitution is the pre-R9 bug.

  • Denominator is totalSupply (never current NAV × factor), shipped per R9. accrued_income is NOT in the numerator (v3-90 dormant); fund_value is not used directly (realized-loss driven).
  • rawNav ≤ 0 sets escalate_flag — surfaced for IMPAIRED/WIND_DOWN, never silently floored/auto-applied.
  • Material gate to create a proposal: |suggested − pools.nav_per_token| ≥ 0.0001 AND no OPEN proposal for the pool.
  • Tranche-group pools are excluded — their NAV is owned by the POST /tranche-writedown loss-waterfall engine.
  • On approve/override: reserve_consumed = min(live reserveBalance, newLossDelta) where newLossDelta = max(0, currentNav − newNav) × total_deposited (v3-16 min(writeoffs, reserveBalance)). ✅ Both halves amended by v3-109 and shipped: reserve_consumed is always 0 under R8 (consuming reserve is not loss absorption) — apply-proposal.ts passes NO_RESERVE_CONSUMED and writes reserve_consumed: 0, and the contract now rejects a non-zero value outright (NavLib reverts InvalidAmount, so it is unrepresentable rather than merely conventional). Any loss-delta multiplier is totalSupply under R9.

⚠️ Data-source reconciliation (spec named pool_chain_deployments; code differs): the LOCKED formula named pool_chain_deployments.{reserve_balance,total_deposited} (SUM across chains), but that table is unused in this codebase (0088 note) — the indexer mirrors the live on-chain reserve to pools.reserve_balance, and no populated total_deposited column exists. So getLiveReserveAndDeposited prefers pool_chain_deployments when it has rows (honors the multi-chain intent + future deploys) and otherwise falls back to pools.reserve_balance (live reserve) + pools.tvl (net principal). This is the one deviation from the spec's literal column names; the formula's semantics are unchanged.

Result:

  • DB: nav_proposals table (migration 0094, +1 → 45 tables).
  • BE (apps/infra): lib/shared/fund-data/nav-suggest.ts (computeSuggestedNav), lambda/nav-proposals.scheduler.suggest.ts (EventBridge ~12h), lib/shared/nav/apply-proposal.ts + lambda/nav-changes.post.{approve,override}.ts (POST /nav-changes/proposals/{id}/approve|override, ADMIN/SUPER_ADMIN only — OPERATOR excluded), lambda/nav-proposals.get.list.ts (GET /nav-proposals), nav_proposal_pending notification copy, NAV_APPROVE/NAV_OVERRIDE audit event types.
  • FE (apps/admin-web): NAV change dialog renders the suggested value (read-only + snapshot date) with a pre-filled override input → Approve (unchanged) / Override (edited). tsc -b green (deploy-pending — infra has no auto-deploy; run migration 0094 + cdk deploy).

Refs: corrects the multiplicative formula in 06-writedown-nav → How NAV Writedown Is Calculated; 11-db-schema → nav_proposals; reserve absorption v3-16, retired by v3-109 (buffer absorption + totalSupply denominator). Source: JY (product), 2026-07-27.

v3-73 — FE displays ratio config as percent; storage / input / API / contract stay bps (one-way display formatter) ✅ Decided

v3-73 — FE percent display for bps ratio config

Date: 2026-07-13

Context: Rule #7 (migration 0068) unified every ratio value to bps end-to-end — including FE input and display — and forbade any percent↔bps bridge. Product review: bps is the correct storage/transport unit (the contract is integer-only, amount × bps / 10000), but showing investors "1000 bps" is worse UX than "10%".

Decision:

  • Storage, input, API payloads, Lambda, and the contract remain bps (unchanged). No migration, no ABI change, no calculation touched.
  • FE renders ratio config (reserve_bps, redemption_gating_bps, penalty_rate_bps) as percent for DISPLAY ONLY via a one-way formatBpsAsPercent(bps) helper (1000 → "10%", 250 → "2.5%", trailing zeros stripped).
  • Admin operator INPUT stays bps (numeric bps fields + isBps validation) — operators work in the on-chain unit, and keeping input bps means there is no bidirectional conversion to get wrong. Admin create/edit gains a live "= X%" hint beside each bps input and shows percent in the review summary (admin-web FE — tracked in Notion FE-UI-v3).
  • Still forbidden: any bidirectional percent↔bps conversion at the storage / input / transport layer (the thing #7 banned). Only a read-path display formatter is allowed. Precision is lossless because percent is capped at 2 decimals (isPercent) ↔ integer bps.
  • Fee rates unaffectedpool_mgmt_pct / spc_mgmt_pct / platform_yield_take_pct were already percent (isPercent) end-to-end. (Superseded: migration 0075 later unified the fee rates to bps too — *_pct*_bps, ×100, charged ×bps/10000; see 17-changelog.)

Scope: apps/web display strings (risk-tier reason + early-penalty in shared/api/pools.ts, PCWidgets, invest-sidebar, risk-disclosure, overview-tab) + admin review summary (step-save-publish) now use the formatter. Admin input percent-hint = FE-UI-v3 item + Claude Design mockup. CLAUDE.md rule #7 reworded (display=percent; storage/input/API/contract=bps; no bidirectional bridge; display formatter allowed).

Refs: CLAUDE.md rule #7; 24-field-governance; 17-changelog v3-73; Notion FE-UI-v3.

v3-72 — Fund-data DPD/NPL model: NPL = on-book delinquent (leading), cumulative loss = realized write-off (in NAV); dynamic buckets + risk-badge signal ✅ Decided

v3-72 — DPD / NPL data model + risk-badge signal

Date: 2026-07-13

Context: Intern SEA/OJK research on delinquency standards. Findings: collectibility-ratio regulation exists only for ID/VN banks (TH/PH/MY/SG estimate); OJK differs bank vs P2P and SEA P2P has no common ratio standard; write-off timing is discretionary (P2P generally treats 90+ DPD as non-performing); the overdue-ratio denominator is outstanding balance. → Aset ingests what each fund reports; it does not impose a provisioning table.

Decisions:

  • NPL = on-book delinquent balance — loans past npl_threshold_days (fund-reported, default 90) still on the book. A leading, unrealized signal. NPL % denominator = total_outstanding_principal (balance).
  • Cumulative loss = realized write-off, a separate metric — principal removed from the book once past the fund's write_off_policy (discretionary; SEA P2P ~180 DPD). Realized → embedded in NAV (writeoffs term). Not the NPL figure, not a provision. (DB column cumulative_loss, renamed from cumulative_impairment in migration 0074.)
  • Two thresholds, one lifecycle: npl_threshold_days (non-performing, still on book) → write_off_policy (removed → cumulative loss). Never overlap at a point in time. Diagram in 06-writedown-nav.
  • DPD buckets are dynamic — one row per bucket as the fund reports (thresholds vary by jurisdiction), not fixed 30/60/90.
  • Risk badge — 3 distinct signals: nav_per_token (realized loss), npl_ratio (leading/unrealized — NEW, S2), and structural (lifecycle / tranche / reserve_bps). NPL > 5% → High, 2–5% → Medium. cumulative_loss is display-only (already in NAV, not a badge input). Corrects the earlier "NPL embedded in NAV" note; graceful fallback to Phase-1 signals when partner data is absent. See 04-pool-models Risk Levels.

Scope (S2): dynamic DPD display + NPL ratio + cumulative-loss display + NPL% into the badge + sector_breakdown display. Deferred: concentration / avg tenor / utilization (Phase 3); automated NAV writedown (v3-13, needs Joob per-loan data).

Fund-data collection: add npl_threshold_days, write_off_policy; drop dpd_bucket_standard (buckets carry lower_days) and dpd_denominator (fixed = outstanding principal); drop OJK collectibility_categories.

DB / repo (CH — migration 0073 additive + 0074 rename; 0071/0072 were already taken): snapshot gains accrued_income / total_outstanding_principal / active_loan_count / total_overdue_loans / total_overdue_exposure / sector_breakdown; new child table external_pool_dpd_buckets; pools.npl_threshold_days (default 90) + write_off_policy; rename cumulative_impairmentcumulative_loss (0074, ships with BE readers). BE: bucket-store in fund-data sync, npl_ratio at read, admin form fields, computePoolRiskTier NPL branch. (JY already applied the additive columns to the live DB via the SQL editor; 11-db-schema doc syncs with CH's migration commit.)

Open: Joob's actual NPL/write-off basis (confirm none vs sync gap); cumulative-loss-rate denominator (current balance vs cumulative disbursed); badge hysteresis (deferred — recompute from latest snapshot for now).

Refs: 04-pool-models, 06-writedown-nav; 17-changelog v3-72; Notion handoff "DPD/NPL S2".

v3-71 — Field-governance matrix corrected against contracts; on-chain reclassification + open timelock/legal questions 🔧 Decided · items pending

v3-71 — Field-governance matrix corrections

Date: 2026-07-13

Context: Walking 24-field-governance row-by-row against the contracts (PlatformPool, libraries/GovernanceLib, PlatformKYCSoulbound) surfaced several misclassifications and one field that isn't a field.

Corrections (code-verified — applied to the matrix, doc-only):

  • min_investment & capacity are on-chain enforced, not off-chain policy. Both live in the on-chain PoolConfig and are enforced at deposit. minInvestment has no setter (immutable post-deploy → §5 Class B); capacity/hardCap has a raise-only setHardCap with no timelock (rejects any decrease and rejects narrowing an unlimited 0 cap → §2 Class A). Their "locked / raise-only" behavior is the contract's, not a backend policy. Removed from the former §6 (C-lock), which now has no members.
  • accepted_currencies is not immutable. On-chain addStablecoin / removeStablecoin (admin) exist but work only while the pool has no LP (totalSupply() == 0); they freeze (ConfigImmutableAfterDeposit) after the first deposit, and removal is guarded so an in-use coin can't be dropped. Go-forward policy: add-only.
  • "Oracle / periphery address" row removed — not a governed field. The oracle is a role (ORACLE_ROLE, "Service Key") granted/revoked instantly via AccessControl and bounded by its narrow scope (redemption processing, NAV marks, pro-rata yield accounting — it cannot move funds to arbitrary addresses), not by a timelock. "periphery" = the KYC implementation address (its own row below).
  • perf_fee_pct removed — the performance-fee take is not implemented; the field (and perf_hurdle_pct) is dropped from the matrix until built.
  • Yield-claim-recipient wording clarified — there is no recipient field to govern: distributeYield names no one (it only increments the global per-share accumulator) and claimYield always pays its own caller. Redirecting another holder's yield is structurally impossible.
  • Stale kyc_level_required row removed — pool-level KYC/KYB level gating was already dropped in migration 0063 (requires_institutional too); pools now gate by jurisdiction only. Replaced in the matrix by the kyc_jurisdiction_whitelist + enforce_jurisdiction entry. (An earlier draft of this decision wrongly proposed "removing its timelock" — the field no longer exists.) Accredited/QP (investor_tier) gating is a separate backend-side plan — see the KYC/KYB & investor-tier spec.

Verified fact (no change, now documented): reserve_percentage has no direction constraint — it may be raised or lowered via the 7d timelock, bounded only ≤ 100%.

Proposals:

  • KYC contract implementation — instant upgrade (drop the 7d UPGRADE_TIMELOCK). ADOPTED 2026-08-14, in code (UPGRADE_TIMELOCK = 0). What it costs, recorded so it is not rediscovered later: the exit paths read this contract — PoolCommonLib.sol:61 and RedemptionLib.sol:537/639/1048 gate requestRedemption, claimYield, the settlement payouts and the permissionless claimRedemptionFallback on kycContract.canRedeem(...). An implementation answering false for everyone blocks every withdrawal from every pool, and the immutable money-path does not help because it is the eligibility answer that changed. The 7 days were the window in which holders could see that coming and exit first. What survives: propose/execute is still mandatory (_authorizeUpgrade rejects a direct upgradeToAndCall), cancelUpgrade still exists, and all three events still fire — so the change is to the wait, not to the authorization gate or the audit trail.
  • nav_per_token 24h-on-decrease delay — confirm whether this is a legal requirement or an operational guard.

Scope: doc-only for the corrections (the matrix + 03-kyc-identity + 04-pool-models + CLAUDE.md now reflect code as-is); the NAV 24h legal check remains pending; the KYC instant upgrade was adopted and implemented 2026-08-14.

Refs: 24-field-governance, 03-kyc-identity, 04-pool-models; contracts PlatformPool.sol, libraries/GovernanceLib.sol, PlatformKYCSoulbound.sol; 17-changelog v3-71.

v3-70 — Money-path naming unified (capital / fee axis); new canonical 23-money-path doc ✅ Decided

v3-70 — Money-path naming unification + canonical doc

Date: 2026-07-10

Context: The wallet/bank-account terms were split across an inconsistent axis — wallets used fund_wallet / fund_fee_wallet while the fund-data sheet paired them with pool_bank_account (capital) and fund_bank_account (fee), and prose used both treasury and treasury_wallet. This made the deposit money-path wallet easy to confuse with the fee wallet (the root concern behind v3-69). No end-to-end money map existed; the flows were scattered across 04, 07, 09a.

Decision — name every wallet & its fiat off-ramp on one axis (capital vs. fee), sharing a prefix:

  • Investor capital: fund_wallet (on-chain, unchanged) → fund_bank_account (fiat).
  • FM Pool-mgmt fee: fund_fee_wallet (v3-69, live on-chain + DB) → fund_fee_bank_account (fiat).
  • Aset fee: treasury_wallet (canonical; supersedes loose "treasury").
  • Reserve: not a wallet/contract — reserveBalance inside PlatformPool (there is no "reserve contract"; PlatformEscrow removed in v3-11).
  • pool_wallet is deprecated (dead legacy AS_POOL column) — never the capital wallet.

No code change: scheme chosen specifically so no DB migration and no on-chain change are required — live identifiers (fund_wallet, treasury_wallet, reserve_balance) already match. Only the fund-data sheet (rename pool_bank_accountfund_bank_account; old fund_bank_accountfund_fee_bank_account) and doc prose are aligned. The admin_fee_pctplatform_yield_take_pct JSONB rename shipped under v3-69 (migration 0067, then *_pct*_bps in 0075).

On-chain ↔ off-platform boundary: Aset is stablecoin (USDC) end-to-end; the on-chain path begins/ends at fund_wallet / the Pool. Fiat conversion (OTC → bank account) is off-platform, partner-side — Aset records account details for reconciliation only. Redemption funding is symmetric and role-gated (fundRedemption via YIELD_DEPOSITOR_ROLE = fund_wallet), with no fixed/hard-coded address (the only pool-configured destinations are the two withdrawFees legs → treasury_wallet + fund_fee_wallet, both admin-settable per v3-69). Withholding tax is out of scope for the money-path doc.

Scope: new canonical doc 23-money-path (+ ko) consolidating the three streams, naming table, and boundary; cross-links added from 04 and 09a; sidebar entry under Protocol. Also adds 24-field-governance (+ ko) — a one-glance matrix of governance-relevant fund/pool fields (storage · class · editor · timelock), a derived view that cross-references the 08 timelock SoT.

Refs: Google Sheet "a-set Fund Data List (Internal)"; 23-money-path, 04-pool-models, 09a-custody, builds on v3-69.

v3-69 — Fee structure: 3 recipients (Aset / Fund / operation); Pool mgmt routes to fund_fee_wallet at distribution ✅ Decided · 🛠 Done

v3-69 — Fee split by recipient; Pool mgmt → fund_fee_wallet

Date: 2026-07-09

Context: BD finalized the go-forward fee design for next-year listed products (current FJO Aset fee = 0). The old single admin_fee_pct → treasury model does not capture that different fees have different recipients.

Decision — 3 recipients:

  • ① Aset (treasury) ← Platform (yield take) + SPC mgmt (+ Perf, if used).
  • ② Fund (separate fee wallet)Pool mgmt only — the fund manager's fee, sent directly to a separate fund fee-receipt address at distributionfund_fee_wallet, distinct from the deposit money-path fund_wallet (segregates fee revenue from deployed capital; name TBD with CH). Not routed through Aset. Rate is per-deal (DB pool_mgmt_pct); the fee amount is computed off-chain (Lambda) and passed to the contract, consistent with the current fee architecture. Rate changes apply immediately (no timelock). (On-chain bps storage + contract-side calc + on-chain timelock is an optional trustless variant, not required.)
  • ③ Operation (off-chain, not investor fees) ← Investor sourcing (distributing partner's cut) + On/off ramping (Aset pays OTC per deal). Out of BE/contract scope. The old entry-fee (deposit() skim) design is dropped — sourcing is reclassified as operation cost.

On-chain change — shipped: at distribution, withdrawFees splits into two destinations — treasury (Aset fees) + the separate fund_fee_wallet (Pool mgmt). This fee address is not the deposit money-path fund_wallet — the two are distinct. The deposit money-path (90% fund_wallet / 10% reserve, immutable v3-27) is unchanged — distribution stage only.

Timelock policy: fee-side changes — the rate and both fee-destination addresses (treasury, fund_fee_wallet) — are governance-controlled but not timelocked: fee distribution is automatic with no hold, so a timelock would only strand fees at a stale destination during the delay window. Only the deposit fund_wallet (investor capital) keeps its timelock.

DB: net_yield_fee_config — rename admin_fee_pctplatform_yield_take_pct; split management into spc_mgmt_pct (→ Aset treasury) and pool_mgmt_pct (→ the separate fund_fee_wallet). Splitting is required because recipients differ.

⚠️ Superseded in mechanism by v3-102 — the split itself stands

The two-destination fee split, the rates and the no-timelock policy below are all still current. What changed is the entry point: withdrawFees no longer exists as a function. Its two legs were merged into settleYield(stablecoin, netAmount, treasuryAmount, poolMgmtAmount), so the fees now pay out in the same transaction that credits investors — there is no separate fee call and no half-settled period. Read withdrawFees below as "the fee legs of settleYield"; the FeesWithdrawn event kept its name.

Result — implemented end to end:

  • Contract: PlatformPool.withdrawFees(stablecoin, treasuryAmount, poolMgmtAmount)YieldLib.withdrawFees debits the combined draw from unclaimedYield once, then transfers each leg (treasuryWallet, fundFeeWallet) in the same tx, emitting one FeesWithdrawn per leg. A zero leg is skipped; poolMgmtAmount > 0 with an unset wallet reverts FundFeeWalletNotSet rather than burning to address(0). fundFeeWallet is stored in PlatformPoolStorage with a public getter, and GovernanceLib.setFundFeeWallet is an instant admin setter with no timelock (per the policy below).
  • DB (0067): pools.fund_fee_wallet (≠ fund_wallet) + yield_distributions.pool_mgmt_fee_amount; admin_fee_pctplatform_yield_take_pct with new spc_mgmt_pct / pool_mgmt_pct keys. Rates later unified to bps in 0075 (*_bps).
  • Lambda: lib/shared/yield/run-distribution.ts computes platform take (gross × bps/10000) + SPC/Pool mgmt (aum × bps/10000 × days/365) off-chain, and yield-distributions.post.distribute passes poolMgmtAmount through withdrawFeesOnChain into the split call.

Not built (deliberately): on-chain bps storage + contract-side fee calculation + an on-chain timelock — the optional trustless variant. Perf fee stays dormant (v3-96).

Refs: Notion — 플랫폼 Fee (설계 SoT) + CH Handoff (온체인/BE 스펙). 04-pool-models → net_yield_fee_config / Fee Destination, 23-money-path, 17-changelog v3-69. :::

v3-68 — NAV guard rails (circuit breaker / deviation cap / staleness) are operational — no investor UI ✅ Decided

v3-68 — NAV guard rails have no investor-facing display

Date: 2026-07-09

Context: A guard-coverage audit (#12) asked whether the investor app should surface the on-chain circuit breaker / NAV deviation cap / staleness guard. On-chain NavLib._checkNavBound enforces a circuit breaker + bidirectional deviation cap on every updateNAV, and RedemptionLib.executeEpoch enforces staleness at epoch settlement.

Decision: these guard rails are operational, not investor-facing — the investor app surfaces no breaker/staleness indicator.

  • Enforcement + app path (backend): nav-changes.post.propose pre-flights via simulateUpdateNav (viem simulateContract / eth_call dry-run) and decodes the custom error (CircuitBreakerActive / NavDeviationExceeded / InsufficientReserve / …) into a clean 4xx — no wasted gas, no DB drift. The deviation cap + staleness threshold have no on-chain getter (setters only), so the simulate is the SoT; they are deliberately not mirrored to the DB.
  • Why no investor UI: resetting the breaker / setting the cap are PAUSER/ADMIN multisig actions — there is no investor-actionable state. Staleness at settlement is handled by the epoch scheduler's epoch_circuit_breaker / epoch_execute_failed ops alerts. Investors see NAV movement through the writedown banner + NAV history, which is the meaningful, actionable signal.

Scope: no contract / DB / frontend change. Documentation-only clarification of an already-enforced invariant.

Refs: 06-writedown-nav → NAV Guard Rails, 17-changelog v3-68. NavLib._checkNavBound, RedemptionLib.executeEpoch, nav-changes.post.propose.ts.

v3-67 — Epoch redemption gating cap base = `totalDeposited`, not TVL (doc/comment correction) ✅ Corrected

v3-67 — Redemption gating cap base = totalDeposited

Date: 2026-07-08

Context: Docs (04-pool-models, 07-redemption) and even the contract's own comments/tests described the epoch redemption gating cap as gatingBps × TVL. The actual on-chain code (RedemptionLib._epochFillRatio) computes gatingCap = totalDeposited × redemptionGatingBps / 10000 — the base is totalDeposited, not TVL.

Clarification (no behavior change): the per-epoch gating cap is redemptionGatingBps/10000 × totalDeposited, applied to available (reserve + heldFundReleases + epoch top-up) before the fill-ratio math. totalDeposited is the on-chain deposited-capital accumulator — += deposit (deposit), += reinvested yield (reinvest / YieldLib), −= payout (redeem) — i.e. book capital, not TVL (LP supply × NAV, market value). The two diverge under writedown (NAV < 1) or undistributed yield, so gating is measured against book capital.

Fix: corrected the "TVL" label in 04-pool-models + 07-redemption and in the contract comments (PlatformPoolStorage, RedemptionLib, PlatformPool) + test comments (PlatformPoolEpoch.t). Code unchanged.

Refs: 04-pool-models, 07-redemption, 17-changelog v3-67. RedemptionLib.sol _epochFillRatio.

v3-66 — `NO_EARLY` means no lockup (`lockup_days = 0`) ⤳ Superseded by v3-83

v3-66 — `NO_EARLY` = no lockup

Date: 2026-07-08

⚠️ Superseded by v3-83 (2026-07-20). Lock-up and penalty_type are now independent — a NO_EARLY pool may have lockup_days > 0 (LOCKED during the lock-up, then penalty-free exit). The config invariant below and its on-chain guard (NoEarlyRequiresZeroLockup) were removed. The rest of this card is kept for history.

Context: Docs (04-pool-models, 05-investment-lifecycle, 07-redemption, CLAUDE.md) framed penalty_type = NO_EARLY as "no penalty on early redemption, so the investor can redeem even during the lockup period" — implying a lockup exists but is penalty-free, and even documented a NO_EARLY + LOCKED badge/redemption state. This misreads the intent.

Decision: NO_EARLYno lockup. A pool with penalty_type = NO_EARLY has lockup_days = 0 and therefore no LOCKED state — the investor can redeem anytime, penalty-free from the moment of deposit. A lockup (lockup_days > 0) is meaningful only together with a penalty type (FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED). This is already consistent with the redemption mechanics ("if lockup_days = 0, skip LOCKED state").

Config invariant: penalty_type = NO_EARLYlockup_days = 0.

Enforcement (done): docs + CLAUDE.md corrected. The invariant is rejected at pool create (pools.post.createpenalty_type NO_EARLY requires lockup_days = 0) and on-chainGovernanceLib.setLockupDays reverts NoEarlyRequiresZeroLockup when a positive lockup is set on a NO_EARLY pool (2026-07-16), so a direct factory config call cannot bypass it.

Refs: 04-pool-models, 05-investment-lifecycle, 07-redemption, 17-changelog v3-66.

v3-65 — Fund Data grounding: total_subscribed, fund-level service providers, NPL/write-off delinquency ✅ Decided · 🛠 Done

v3-65 — Fund Data grounding

Date: 2026-07-07

Context: PDP Fund Data review found a stat with no backing column ("Total subscribed") and a delinquency block implying a full loan-book DPD distribution Aset does not receive.

Decision:

  • Total subscribed — persist the fund-reported cumulative committed principal (already a fund-data request field / summary.fund.totalSubscribed) as external_pool_data_snapshots.total_subscribed (0054). Aset's own subscribed is computable from deposits; the fund-level figure is fund-reported and stamped onto snapshot rows at sync.
  • Service providers — modeled at the fund level (fund_service_providers, 0054), not per-pool; entered via admin create/edit fund and surfaced read-only in the pool Fund Data tab. Inherited by the fund's pools.
  • Delinquency = NPL / write-offs — the Fund Data delinquency block is framed around loan_writeoffs (charged-off NPL cases actually received at loan level), not a full performing-loan DPD distribution. Empty state when none.

Status: ✅ Built (migration 0054; funds providers CRUD + GET /pools/{id}/fund-data/writeoffs; admin create/edit fund providers editor; investor Fund Data tab written-off-loans + providers sections).

Refs: 11-db-schema, 17-changelog v3-65.

v3-64 — Reinvest is same-pool only (no cross-pool reinvest) ✅ Decided · 🛠 Done

v3-64 — Reinvest is same-pool only

Date: 2026-07-04

Context: Docs framed "cross-pool reinvest" as a future (V2) capability, and the investor portfolio UI shipped a "Target Pool" dropdown listing all active pools — implying yield from pool A could be reinvested into pool B. That is structurally impossible and was a critical, misleading affordance.

Decision: Reinvest is same-pool only, by design. On-chain reinvest(uint256) spends the caller's accruedYield in that pool and mints that pool's LP — there is no source→target parameter, and the /yield/reinvest handler only reinvests the given pool's own accrued yield. There is no "cross-pool reinvest" feature — not V2, not roadmap. To allocate yield to a different pool, the investor claimYield() to their wallet and makes a normal deposit() into the other pool.

Scope: FE — removed the "Target Pool" selector + cross-pool branch in apps/web/app/routes/portfolio.tsx (reinvest always targets the source pool; tsc -b green). Docs — 02-core-concepts + 05-investment-lifecycle reworded (dropped the "cross-pool reinvest is V2" line). Backend/contract were already same-pool only — no change.

Refs: 02-core-concepts, 05-investment-lifecycle, 08a-contract-reference, 17-changelog v3-64.

v3-63 — Reserve recovery is pool-level, not per-holder ✅ Decided · 🛠 Done

v3-63 — Reserve recovery is pool-level

Date: 2026-07-02

Context: The PDP "reserve recovery" bar was framed per-holder, which misleads — in a wind-down every holder in the same tranche is repaid at the same pro-rata rate. There is no per-holder recovery rate.

Decision: Present recovery at pool level — a recovery bar from nav_per_token vs par (with reserve_percentage / nav_history.reserve_consumed), plus the viewer's own expected amount (portfolio_positions.effective_value × recovery NAV) and an "everyone recovers at the same rate" note. No new backend. (Recorded here to stop the per-holder framing from recurring.)

Status: ✅ Built (position-card.tsx, WIND_DOWN branch).

Refs: 07-redemption, 06-writedown-nav, 17-changelog v3-63. FE-UI PDP-6 (Notion).

v3-62 — Number / USD / date display conventions ✅ Decided

v3-62 — Number / USD / date display conventions

Date: 2026-07-02

Decision: Standardize numeric and value display app-wide:

  • Numbers: all metrics/tables use tabular-nums, consistent decimal places, right-aligned on the decimal.
  • USD: cards / metrics use compact ($28.5M); tables / tooltips / detail use full ($142,400). USD-only (v3-24) retained.
  • Dates: countdowns / lifecycle use relative (15 days left); maturity / fixed dates use absolute (Jul 15, 2026).

Implemented via shared formatUsd() / date helpers defined once, applied app-wide alongside the shared SectionCard / StatCard tokens (tokenized together to avoid blind global-replace regressions). FE presentation only — no schema / contract impact.

Refs: 17-changelog v3-62, design NOTES.md. FE-UI DC-4 (Notion).

v3-61 — Per-investor cap (`max_investment`) hidden; contract field left dormant ✅ Decided

v3-61 — `max_investment` hidden, field dormant

Date: 2026-07-03

Context: max_investment is an on-chain per-investor cumulative cap (PlatformPool.solpositions[msg.sender].totalInvested; 0 = check off). It's a non-standard primitive versus benchmark platforms (Centrifuge / Maple / Ondo / Securitize), where per-investor control comes from KYC / eligibility gating and pool-wide sizing is capacity.

Decision: Hide max_investment from the pool create / edit forms and always send 0 (unlimited); forms keep only capacity (pool-wide). The contract field stays dormant — not removed: Clones are immutable, so removing it would need a new impl + Factory redeploy, and 0 is a no-op at zero cost. If anti-whale / fair-allocation / regulatory per-investor limits are needed later, re-attach the UI — no contract change required.

Note: Regulatory fit (a fixed per-pool cap vs real per-investor income / net-worth / annual / platform-aggregate limits) is a separate legal check.

Refs: 11-db-schema (max_investment), 04-pool-models (edit matrix — Class B immutable), 17-changelog v3-61. FE-UI CF-3 (Notion).

v3-60 — Country code canonical format = ISO alpha-3 ✅ Decided (reversed from alpha-2)

v3-60 — Canonical country code = ISO alpha-3

Date: 2026-07-07 (reversed — originally decided alpha-2 on 2026-07-02)

Context: Jurisdiction gating compares the pool's kyc_jurisdiction_whitelist against the SBT's on-chain countryCode. SumSub returns alpha-3 (IDN, KOR) and resolveApplicantCountry stores it raw (sumsub/client.ts), so minted SBTs already carry alpha-3, and the on-chain governance path (pools.post.governance.ts) already validates alpha-3 (/^[A-Za-z]{3}$/). So the live pipeline SumSub → DB → SBT → on-chain whitelist is already alpha-3 end-to-end. The only mismatch is off-chain: the admin whitelist UI hints tell admins to enter alpha-2 ("e.g., KR, SG"), the off-chain kyc-gating.ts comparison does a plain uppercase match with no conversion, and the contract docstring (08a §KYCData) labels countryCode alpha-2 — so an alpha-2 whitelist entry (KR) never matches a stored alpha-3 country (KOR) → silently rejects everyone when gating is on.

Decision: Canonicalize on ISO 3166-1 alpha-3 everywhere ("KOR", not "KR"). This reverses the 2026-07-02 alpha-2 decision — the earlier direction required a BE normalization layer that never shipped, and alpha-3 is already the reality across SumSub, the DB, the SBT, and on-chain validation. No BE normalization needed; instead fix the off-chain alpha-2 assumptions to alpha-3.

Remaining work: ① admin UI — change the whitelist hint/placeholder in pool-create / pool-edit step-risk-penalty.tsx to alpha-3 ("e.g., USA, KOR, SGP"); ② handlers — add alpha-3 format validation (exactly 3 uppercase letters) on create/update, matching the on-chain governance check; ③ contract docstring — correct 08a §KYCData countryCode from alpha-2 → alpha-3 (governance.ts:41 type comment too); ④ migrate any legacy alpha-2 rows/SBTs to alpha-3 (SumSub new writes are already alpha-3). The country.ts ['US','USA'] dual-form stopgap can stay (harmless).

Refs: 04-pool-models, 03-kyc-identity, 08a-contract-reference, 17-changelog v3-60. B1 (Notion).

v3-59 — DPD delinquency buckets generalized (fixed 30/60/90 → jurisdiction-defined dynamic) ✅ Decided · 🚧 To build

v3-59 — Dynamic DPD buckets

Date: 2026-07-03

Context: The PDP delinquency section shows real partner data, but the ingest model hard-codes 30/60/90-day buckets. Other regulators bucket differently — e.g. Indonesia's OJK collectibility uses five categories (current / special mention / substandard / doubtful / loss) on a 90/120/180 boundary set. A fixed {30, 60, 90} object can't represent them, so SEA/OJK partner data can't be received faithfully. This generalization is a prerequisite for the intern SEA/OJK research landing.

Decision:

  • Buckets are dynamic, not a fixed {30/60/90} object — each fund reports its own boundaries.
  • DB = child-table (Option A): pool_dpd_buckets(snapshot_id, lower_days, loan_count, exposure) — one row per bucket. The parent risk snapshot gains dpd_bucket_standard (e.g. "OJK collectibility" / "30/60/90 internal") and npl_threshold_days (NPL cutoff, default 90); an optional dpd_denominator records the ratio base (outstanding principal vs active loan count).
  • FE = no change: the delinquency renderer (PDP-1) is already dynamic — it absorbs whatever bucket array the API returns.

Why child-table over JSON: a normalized child table keeps buckets queryable/sortable (ORDER BY lower_days), validatable (no overlaps / negatives), and time-series friendly, without widening the snapshot row per jurisdiction.

Scope (🚧 not yet built) — three seams: ① partner Google Sheet ("Data Mapping" tab): dpd_buckets fixed object → array [{lower_days, count, exposure}] + new dpd_bucket_standard / dpd_denominator / npl_threshold_days fields; ② DB: add pool_dpd_buckets + the two snapshot columns (new migration); ③ ingest / API mapper: accept the array, sort by lower_days, validate, expose it verbatim for the FE to iterate. No on-chain impact — DPD data is ingest+display-only (not used in NAV, which is the separate v3-13 per-loan pipeline). Pairs with the intern SEA/OJK research (standard buckets, NPL cutoff, write-off timing, denominator conventions).

Refs: 06-writedown-nav, 20-joob-pool-config, 11-db-schema (external_pool_data_snapshots, loan_writeoffs), 17-changelog v3-59. FE: PDP-1 (dynamic render, done).

v3-58 — Permissionless LP transfers: whitelist gate removed (verification enforced at value boundaries) ✅ Decided · 🛠 Done

v3-58 — Permissionless LP transfers

Date: 2026-07-02

Context: PlatformLPToken gated investor-to-investor (secondary) transfers behind an admin-managed whitelist mapping — a transfer reverted NotWhitelisted unless both from and to were on the allowlist. But no backend/app code ever called setWhitelist (only the contract tests did), so in practice the mapping was empty and secondary transfers were effectively blocked for everyone. This also conflicted with the 5-state holder model, whose State C (Unverified Holder) explicitly exists for wallets that received LP via transfer without registering with Aset — impossible under a whitelist that no one is on. The whitelist was implementation inertia, not a live compliance control.

Decision: Remove the whitelist gate entirely. Secondary LP transfers are permissionless — neither party needs an allowlist entry. Verification is enforced at the value boundaries instead, which was already true on-chain: deposit() carries requiresKYC and requestRedemption() / claimYield() carry requiresRedeemableKyc (canRedeem). So a wallet can freely receive and hold LP (→ State C), but cannot deposit, redeem, or claim yield until it completes KYC (→ State A). Admin pause() still freezes all secondary transfers for emergencies (N-3/N-6); mint/burn/redemption-lock paths are unaffected.

Why this is safe (not a compliance regression): LP represents a security, but the gate that matters is value in / value out, not token movement. Even LP acquired off-platform (secondary market, P2P) must pass KYC to redeem or claim on our platform — the exit is gated, so an unverified holder can never extract value. This matches the "gate value in, never trap value out" principle (v3-19).

Scope / safety: PlatformLPToken.sol — removed whitelist mapping, setWhitelist, NotWhitelisted error, WhitelistUpdated event, and the two _update checks; kept _requireNotPaused() + onLpTransfer yield settlement. Tests updated (removed 3 whitelist-only tests, flipped 2 revert tests to a positive permissionless test, dropped setup setWhitelist calls across 6 suites) — 291/291 green. No backend/app referenced the whitelist. Existing pools are NOT retroactively affected (clones immutable) — requires redeploy of a new LP impl + Factory for the new behavior to apply to freshly created pools.

Refs: 01-overview, 08-smart-contracts, 08a-contract-reference, 21-holder-verification, 05-investment-lifecycle, 17-changelog v3-58.

v3-57 — Pool Updates (WO-6) RBAC formalized: Operator opt-in, soft-delete retention, MATERIAL_EVENT revision audit ✅ Decided · 🛠 Done

v3-57 — Pool Updates / Announcements permissions

Date: 2026-07-02

Context: The per-pool Updates / Announcements feed (v3-22 / WO-6) shipped — migration 0012 (pool_updates + pool_update_revisions) and endpoints POST /pools/{id}/updates, GET /pool-updates, PATCH/DELETE /pool-updates/{id} — but its permission model lived only in code, not in the docs. Formalized here (one behavior change: Operator).

Decision (permissions):

  • Create / edit / delete = SUPER_ADMIN, ADMIN, FUND_MANAGER, and OPERATOR opt-in via the admin-granted pools page permission (same gate as viewing; previously Operator was hard-excluded). An authorized Operator acts platform-wide like an Admin.
  • FM posts only on pools of their own fund (requireFundAccess) and may edit/delete only their own posts; Admin / Operator / Super Admin may edit/delete any entry.
  • Categories INFO / IMPORTANT / MATERIAL_EVENT; IMPORTANT + MATERIAL_EVENT email all holders on publish, INFO is in-app feed only. No separate admin-review gate before an FM material-event blast — the FM owns disclosures for their own fund, and Admin/Operator retain post-hoc edit/delete.
  • Delete is soft-only (deleted_at): the row is retained (hidden from feeds), never hard-purged — permanent audit trail. Editing a MATERIAL_EVENT (or recategorizing into/out of it) snapshots the prior version to pool_update_revisions before the edit lands.
  • Updates cannot be created on a DRAFT pool.

Scope / safety: Off-chain only; no contract/schema change. The pools page-permission gate reuses admin_user_permissions; requireFundAccess no-ops for non-FM roles. Code: pool-updates.post.create / .patch.update / .delete (add OPERATOR + requirePagePermission('pools')) + .get.list. tsc green.

Refs: 09-rbac → Pool Updates, 11-db-schema (pool_updates / pool_update_revisions, 0012), 15-api-reference → Pool Updates, 17-changelog v3-57, v3-22 (feed spec).

v3-56 — Full-amount LP minting: reserve split no longer dilutes the investor's claim ✅ Decided · 🛠 Done

v3-56 — Full-amount LP minting

Date: 2026-07-02

Context: Since the very first contract (2026-02-03, EmergeFiPool), deposit()/reinvest() minted LP net of reserve: lpAmount = (amount − reserve) × NAV_PRECISION / nav. With the default 10% reserve_percentage, a $100 deposit minted only 90 LP — the investor's redeemable claim (LP × NAV) was silently 10% below their deposit, and the reserve portion was never individually credited back (only pro-rata at WIND_DOWN). This was implementation inertia, not a recorded product decision.

Decision: LP is minted against the full amount: lpAmount = amount × NAV_PRECISION / navPerToken (deposit and reinvest). The reserve split is unchanged as a money flow (reserve retained in reserveBalance, remainder released to fund_wallet) but is now a pool-level liquidity allocation only — reserve and LP are independent. A $100 deposit mints 100 LP at NAV 1.0.

Scope / safety: PlatformPool.deposit() + reinvest() (2 lines) + backend mirror calculateLpTokens() (used for the deposit cross-check and the reinvest DB credit — must match on-chain) + 26 tests updated (295/295 green). Existing pools are NOT retroactively affected (clones immutable) — old Sepolia test pools keep net-of-reserve minting and should be archived/recreated; reinvest estimates against old pools would over-credit by reserve_percentage. Redeployed as new PoolImpl + Factory (see apps/contract/sepolia.md).

Refs: 05-investment-lifecycle (step ④ formula), 08-smart-contracts, apps/contract/sepolia.md (2026-07-02 round), 17-changelog v3-56.

v3-55 — Partner fund-data metrics renamed asset-class-neutral (total_rni/total_npl → realized_income/cumulative_impairment) ✅ Decided · 🛠 Done

v3-55 — Neutral naming for partner fund-data metrics

Date: 2026-06-30

Context: The partner data-intake fields total_rni ("Realized Net Income") and total_npl ("Non-Performing Loans") on external_pool_data_snapshots are credit-fund-specific (Joob) and partly misleading: (1) total_rni is labeled "Net" but is actually gross of fees (per the rni_gross_or_net clarification — Aset deducts fees), and (2) total_npl's "90d+ default" framing assumes a loan book and doesn't map to real-estate / receivables / fund-of-funds. The platform onboards multiple asset classes (every pool is a full on-chain investable pool with a fund_id), so the canonical names should be asset-class-neutral.

Decision: Rename the DB columns + our domain types + our API response + FE to neutral names: total_rni → realized_income (realized income, gross of fees) and total_npl → cumulative_impairment (cumulative principal impaired / written down). The credit-specific terms (RNI/NPL, 90d default) survive only as examples in the partner intake sheet, not as canonical identifiers.

Scope / safety: These columns are ingest + display only — they do not feed NAV (NAV uses DPD buckets + OJK write-off schedule, v3-13/14) and never touch an on-chain call, so the rename is non-breaking. The partner (Joob) wire field names totalRni/totalNpl are unchanged; the provider mapper (lib/shared/fund-data/providers/joob/mapper.ts) is the seam that translates partner naming → our canonical columns, so partners are unaffected. Migration 0043 (ALTER TABLE … RENAME COLUMN). The partner intake sheet wording (incl. the "Net" fix) was updated separately.

Refs: 11-db-schema (0043), external_pool_data_snapshots, 17-changelog v3-55. Partner intake sheet (Google Sheets).

v3-54 — FM wallet signing hardening: fund_wallet visibility + client-sign enforcement + bound-wallet display (W8/W9) ✅ Decided · 🛠 BE+FE done (deploy pending)

v3-54 — FM Wallet Signing Hardening (W8/W9)

Date: 2026-07-01

Context: v3-53 moved money-in to FM client-signing but left three gaps that block turning off the dev fund_wallet == adminWallet shim: FMs can't see their pool's fund_wallet, the yield/redemption routes silently fell back to the server key when no matching wallet was connected (non-custodial guarantee leaks), and SIWE-bound wallets were invisible with no rebind path. This decision closes them (Notion W8a/W8b/W9).

Decision (W8a — fund_wallet visibility): The full fund_wallet display (address + explorer + copy) was Admin-only (Controls tab). Extracted to a shared FundWalletDisplay component and added as a read-only row in the Overview tab "Pool Config Summary" (all roles, incl. FM). No API changePoolView.fundWallet already existed.

Decision (W8b — client-sign enforcement, no silent fallback): The depositYield (yield.tsx) and fundRedemption (redemptions.tsx) routes no longer silently fall back to the server key. Rule: any pool with a fund_wallet set requires client-signing — if the matching wallet isn't connected the action is blocked with an explicit error (no server-key path). Since every pool carries a fund_wallet (v3-26) and the server key has no on-chain permission for these two functions on non-shim pools, this is consistent with on-chain reality. Simplification vs. the original plan: we do not branch on fund_wallet == adminWallet (admin-web has no admin-wallet address) — the dev/admin server-key test convenience is dropped; dev testing connects the pool's fund_wallet directly. FE-only; the flow hooks already blocked on mismatch.

Decision (W9 — bound-wallet display + rebind, A안): New GET /admin/me/wallets returns the caller's own SIWE-bound wallets (scoped by auth.sub; no cross-user reads). My Settings' Signing Wallet card lists verified wallets and offers connect + re-verify (rebind, EOA/Safe) reusing the existing bind flow. Actual on-chain fund_wallet change stays an admin governance action (7-day timelock) — FMs get a notice to request it, no FM-executed governance (BE permission unchanged). Binding remains display/audit only; on-chain msg.sender == pool.fund_wallet is the authz SoT.

Implementation: BE: admin-user-wallets.get.list.ts + route /admin/me/wallets (no migration — admin_user_wallets exists). FE (admin-web): FundWalletDisplay extraction + Overview row; fmSignBlocked guard in yield/redemptions routes; admin-user-wallets api/hook/query-key, WalletBindCard bound-list + rebind, My Settings governance notice. Contract: 0 changes. Migration: 0. Deploy order unchanged: W8 merge → remove dev shim → real fund_wallet.

Refs: 15-api-reference → Admin Wallets, v3-53, 17-changelog v3-54. Notion: "🛄 Fund Manager 지갑 추가" (W8a/W8b/W9).

v3-53 — FM wallet signing: depositYield / fundRedemption client-signed (B3 + 2-phase split) ✅ Decided · 🛠 BE+FE done (deploy pending)

v3-53 — FM Wallet Signing (client-signed money-in)

Date: 2026-06-30

Context: Non-custodial principle: Aset must not hold a key that moves user funds. The only two on-chain functions the contract gates to the partner (YIELD_DEPOSITOR_ROLE on the pool's fundWallet) are depositYield and fundRedemption (PlatformPool.sol:931/774) — both pull USDC from msg.sender, so the signer must hold the funds. In dev these were signed by the Aset server key via the fund_wallet == adminWallet shim, which is custodial and breaks in production. This decision moves both to FM client-side signing from the FM's own wallet.

Decision (custody = Model A): The FM's connected wallet is the pool's on-chain fundWallet (no separate operator wallet, no hop, no contract change). EOA or Safe is the fund's choice; Safe is recommended for key-separation but not enforced.

Decision (FM wallet identity = B3): An FM proves wallet ownership via SIWE and the proven wallet is stored in the new admin_user_wallets table (one FM → N wallets, since each pool may carry a different fund_wallet). Authorization SoT remains on-chain (msg.sender == pool.fund_wallet, enforced by the contract); the table is the proven-wallet set used for the pre-sign guard + display + audit (avoids DB↔chain drift). SIWE bind is session-less — the FM keeps their Google-OAuth admin session; POST /admin/wallet/nonce + POST /admin/wallet/verify reuse siwe.ts and only record the binding (no JWT minted). The wallet-connect layer is an additive "signing connection" (wagmi/RainbowKit, Safe connector) — not a login method.

Decision (2-phase split + indexer hand-off):

  • Yield: POST /yield-distributions branches on a new deposit_tx_hash body field — when present (FM path) the row is created in PROCESSING (reusing the existing-unused yield_status value — no enum change) recording the FM's on-chain depositYield tx, and the server-key steps are deferred. A new POST /yield-distributions/{id}/distribute verifies the deposit on-chain (indexed yield_funding_events row or tx-receipt decode of YieldDeposited), then runs distributeYield + withdrawFees + accrual + notify (shared lib/shared/yield/run-distribution.ts, also used by the unchanged legacy/dev path) → DISTRIBUTED. Idempotent; returns 409 until confirmed.
  • Redemption: FM client-signs fundRedemption; POST /redemption-requests/{id}/record-funding only records the tx (funding_status='PENDING_FUND_CONFIRMATION', funding_status is free-text TEXT — no migration). The contract auto-completes the payout and the indexer settles via RedemptionCompletedcomplete_redemption_atomic (LP burn + TVL + COMPLETED). A separate /complete endpoint was explicitly rejected — it would race the indexer and double-burn.
  • Rejected: adding FUND_MANAGER to the server-key /fund endpoint — unnecessary, the FM no longer goes through it.

Implementation: Migrations 0041 (admin_user_wallets) + 0042 (yield_distributions.deposit_tx_hash), both applied to dev. BE: admin-wallet.post.{nonce,verify}.ts, yield-distributions.post.distribute.ts, redemption-requests.post.record-funding.ts + routes. FE (admin-web): wagmi/RainbowKit providers, SIWE bind UI (my-settings), use-deposit-yield-flow / use-fund-redemption-flow (approve→action 2-tx) + use-network-guard, full fund_wallet display + copy/explorer, pool-create fund_wallet input, FM visibility gate removed on Record Distribution. Contract: 0 changes. Phased Phase 1 (foundation) → Phase 2 (yield) → Phase 3 (redemption). Needs pnpm cdk:deploy + admin-web deploy + the dev shim (fund_wallet==adminWallet) removed in the right order (FE/BE/deploy) before the real money-path works.

Follow-ups (deferred): Safe async execution (proposal → indexer-detected execution) is EOA-first today; signing-specific notification deep-links (per-pool); admin_user_wallets.chain_id if Safe addresses diverge per chain.

Refs: fm-wallet-signing-spec, 11-db-schema (0041/0042), 09-rbac → Off-chain↔On-chain, 17-changelog v3-53. Notion: "🛄 Fund Manager 지갑 추가".

v3-52 — Investor display name = KYC legal name (non-editable) + SumSub backfill ✅ Decided · 🛠 BE done (deploy pending)

v3-52 — Investor Display Name = KYC Legal Name

Date: 2026-06-29

Context: The investor Settings page showed "Anonymous Investor" for everyone — users had no human name, only email/company_name. Migration 0040 added first_name/last_name and apply-review (GREEN) now mirrors the SumSub-verified name into them. Two open questions remained: (1) should the displayed name be strictly the KYC legal name, or an editable alias/nickname the investor controls; (2) existing approved investors (KYC'd before 0040) still have NULL names → still "Anonymous".

Decision (A — legal name only, non-editable): The display name is the KYC legal name sourced from SumSub and is not investor-editable. No separate display_name/alias column. Rationale: the name is not cosmetic — per WO-4 the Fund Manager sees investor name + wallet for KYC/AML purposes, so a self-asserted alias would defeat the compliance value (FM must see who the legal counterparty is). Settings renders it read-only. An editable alias (scoped to the investor's own view only) is a possible future add if a real UX need appears, but explicitly out of scope now — mixing legal + alias would force "FM sees legal, self sees alias" branching at every exposure point and confuse users (their edit wouldn't change what FM/admin see).

Decision (backfill): Existing approved investors with NULL name are filled retroactively — no re-verification — by kyc.scheduler.reconcile-sweep (existing 5-min sweep). New backfillMissingNames(): query kyc_status=APPROVED & sumsub_applicant_id present & first_name/last_name/company_name all NULL → getApplicantInfo → write individual first_name/last_name or KYB company_name. No new PII collection — only mirrors data SumSub already holds. Self-draining (returns nothing once all names exist); also heals any single user whose GREEN webhook predated 0040. 25/run batch; SumSub returning no name → skip (retried next run, not marked done).

Implementation: kyc.scheduler.reconcile-sweep.ts + getApplicantInfo. No new migration (rides 0040). Needs pnpm cdk:deploy + migration 0040 applied — until then names stay NULL and the page shows "Anonymous Investor" by design.

Refs: 11-db-schema (0040), 17-changelog v3-52, WO-4 (FM PII scope). Tracker: investor Settings.

v3-51 — Yield reconciler: claimable_yield = on-chain pendingYield (미청구 + 적립) ✅ Decided · 🛠 BE done (deploy pending)

v3-51 — Yield Reconciler (claimable_yield mirror)

Date: 2026-06-29

Context: The investor "claimable yield" shown by the app read only portfolio_positions.accrued_yield — the settled, admin-distributed mirror written by yield-distributions.post.create (pro-rata of the net distributeYield). That value drifts from on-chain truth in three cases: (1) a partner distributes on-chain directly (distribution_type=ONCHAIN, no accrued write), (2) an investor calls claimYield() on-chain directly (DB stays stale-high), (3) LP balances change (transfer/reinvest) so the per-share yieldDebt recomputes. The contract already exposes the authoritative total — pendingYield(investor) — but nothing read it back. (Tracker #7.) ⚠️ The formula behind that getter changed in v3-131 (3): it is now claimableYield + unfundedYield, and it excludes yield attached to an open redemption request (previewEscrowAccrual(requestId)), so a reader that wants an investor's true total must add the open requests.

Decision: Add a yield reconciler (BE/indexer only — the contract already has everything; no contract change):

  • portfolio_positions.claimable_yield + claimable_yield_synced_at (migration 0038) — the reconciled on-chain pendingYield (settled + unsettled), human USD. NULL synced_at = never reconciled → FE falls back to accrued_yield.
  • yield.scheduler.reconcile (hourly EventBridge) — for each deployed pool with holders, read pendingYield(walletAddr) per holder and upsert claimable_yield. Read-only on-chain (no admin key), DB writes only.
  • YieldClaimed(investor, amount) indexer — new watched event (writeYieldClaimed): on a direct on-chain claim, refresh that holder's claimable_yield from chain and settle the yield_claims ledger (complete the API row, or insert a COMPLETED audit row).

Display: FE total claimable = claimable_yield when claimable_yield_synced_at is set, else accrued_yield (graceful degrade before the first sweep / on RPC error). FE wiring is a follow-up — no API change, portfolio-positions already returns all columns.

Units: yield is normalized to 18 decimals on-chain (distributeYield netAmount, accruedYield, pendingYield); the reader does formatUnits(value, 18) → human USD to match accrued_yield.

Refs: v3-20 (manual yield), 05-investment-lifecycle, 11-db-schema, 17-changelog v3-51. Tracker: FE-UI-v3 #7.

v3-50 — Tranche loss waterfall: build BE capability now (loss-only) + create-flow = Method A ✅ Decided · 🛠 BE + admin create-FE done

v3-50 — Tranche Loss Waterfall: BE Capability + Create-Flow (Method A)

Date: 2026-06-29

Context: v3-14 defined the tranche structure and v3-39 chose the off-chain (Lambda) trust model — but the actual loss-waterfall computation was never built. nav-changes.post.activate.ts only updates the target pool's own nav_per_token; there is no group lookup, no Junior-first absorption, no Junior→IMPAIRED trigger. (Group-constraint validation at create — same fund_wallet/operating_currency/maturity_days, role uniqueness, max 3 — is built: pools.post.create.ts L438–475.) FE = 0%. So tranche pools today "exist in name only": create a Senior/Junior group and a loss would not be absorbed Junior-first. With a confirmed Senior-tranche deal in the pipeline, decided to complete the BE capability now — it is contract-independent (per v3-39, Lambda computes per-pool NAV then calls updateNAV() on each pool) so it does not sit on the smart-contract audit critical path.

Decision — build as a platform capability, not deal-gated: finish the BE so any tranche group works end-to-end; FE is per-deal / deferred.

Decision — scope = LOSS waterfall only (v1): Junior NAV absorbs first → Mezzanine → Senior (per 04 → Tranche Group Waterfall), plus Junior→IMPAIRED auto-transition when its NAV hits 0 (reuses v3-12). The yield (profit) waterfall (Senior gets its target APY first) is deferred until a deal explicitly requires Senior-APY-first — loss protection is the core of a tranche; profit ordering is not needed for it to function as a safety structure.

Decision — role assignment: tranche_role (SENIOR/MEZZANINE/JUNIOR) + tranche_group_id are chosen explicitly by the operator at pool-create only — not auto-derived. Immutable after creation (Senior relies on the subordination promise; there is no pool-edit path today, so this is naturally enforced — and must be hard-frozen once the group has any deposit). Soft warning (not a block) if a Senior pool's apy_rate ≥ a Junior pool's (Senior should yield less).

Decision — create flow = Method A (incremental), not a one-shot wizard: the existing pool-create form gets a Standalone / Tranche toggle → if tranche, pick group (create-new or select-existing) + role radio (roles already taken in the group are disabled) + shared fields (fund_wallet/currency/maturity_days) locked from the group's first pool. Chosen over a multi-pool wizard (Method B) because the BE already supports per-pool create + group validation, and each pool is its own on-chain deploy — an atomic 2–3-pool deploy is fragile (rollback on partial failure). Trade-off accepted: a group can briefly be incomplete (Senior with no Junior yet), handled by the guard below.

Decision — completeness guard: a tranche group cannot accept deposits until its shield is in place (≥ Senior + Junior present). An incomplete group (e.g. Senior only) is flagged and kept non-ACTIVE until completed — prevents a Senior going live with no Junior to absorb first-loss.

Decision — Junior retail-gating = FE-only hide, per-deal. No on-chain deposit allowlist. An on-chain allowlist would require a contract change → breaks "smart-contract impact: zero" and pulls tranche back onto the audit critical path. Consistent with v3-39; a trustless on-chain variant remains a future, non-destructive pool type.

Out of scope (explicit): yield/profit waterfall · on-chain allowlist · pool-edit group validation (no edit path exists). (Admin create-UI, investor #18/#20, and the incomplete-group badge are now done — see status.)

FE status (done):

  • Admin create-flow — pool-create Step 3 "Models" has a Standalone / Tranche group toggle → pick a group (new = fresh UUID, or attach to an existing one) + role radio (roles already taken in the group are disabled) → fund & maturity auto-locked from the group's first member, with a soft warning if Senior APY ≥ Junior APY.
  • Admin pool list — pools in an incomplete group show a Tranche · {role} pill + an amber "Incomplete · needs Senior/Junior" badge (the publish guard in pools.post.lifecycle already blocks going live).
  • Investor (#18/#20) — Senior/Junior/Mezz pill on the pool card + detail header; a tranche row in the Risk Disclosures (Senior = loss-protected / Junior = first-loss); risk-tier now reads tranche_role per the v3-25 spec (Junior → High; Senior & Mezzanine → Medium; standalone stays Low).
  • Data layer in both apps (PoolRow/PoolView/payload + CreatePoolPayload) carries tranche_group_id/tranche_role.

Still deferred (genuinely per-deal): hiding a specific Junior pool from the investor app — done case-by-case when a deal designates a Junior (e.g. LINE), not a generic build.

Implementation: pure waterfall engine apps/infra/lib/shared/tranche/loss-waterfall.ts (+ unit tests against the docs example — Junior $300K, group loss $500K → Junior NAV→0, Senior absorbs $200K) → group write-down entry that fans the loss out to per-pool NAV changes via the existing propose→24h-timelock→activate→applyPendingNavOnChain path (single-pool propose untouched = money-path isolation) → Junior→IMPAIRED trigger → completeness guard. Needs pnpm cdk:deploy (user-owned); money-path-adjacent → QA required. Smart-contract change = zero.

Refs: v3-14 (tranche structure), v3-39 (off-chain trust model), v3-12 (IMPAIRED), v3-32 (NAV guards), 04 → Tranche Group Waterfall, 06 → Step 2, 17-changelog v3-50.

v3-49 — FM yield-distribution due + overdue notifications (scheduler producer) ✅ Decided · 🛠 BE done (deploy pending)

v3-49 — Yield Distribution Due / Overdue Notifications

Date: 2026-06-26

Context: The notification inventory covered yield_distributed (sent after a distribution) but had no prompt to the FM that a scheduled distribution has come due, and no overdue escalation. Yield is manual-trigger (v3-20), so without a nudge an FM can silently miss the cadence — yield_overdue was a DB flag the pools.scheduler.yield-due sweep set, but nothing notified anyone.

Decision: Two new FM/Op events, produced by the existing daily pools.scheduler.yield-due sweep (ACTIVE pools only — UPCOMING has no holders; IMPAIRED/WIND_DOWN don't run a normal schedule):

  • yield_distribution_due — fired once on the next_yield_due → overdue transition (the moment it's due). "It's time to record the distribution."
  • yield_distribution_overdue — escalation when still unrecorded ≥ 3 days past due; re-sent at most every 7 days while it stays overdue.

Idempotency (no schema change): due rides the existing yield_overdue false→true transition (one-shot per cycle; resets when a distribution moves next_yield_due forward). overdue cannot use dedupByEntity (notification_logs.related_entity_id is UUID → would dedupe forever across cycles), so it uses a recent-row lookup (event_type + pool + 7-day window).

Recipients: FM + ADMIN audience (recipient_id = pool id; FM read endpoints scope to own funds). Category YIELD, admin deep-link /yield.

Notes: extends the FM V1 notification scope (not in the original inventory). Copy added to copy.ts; the Notification Copy sheet + the Notification UX/Design Yield table should add the 2 rows. No new lambda/CDK (rides the existing cron); deploy gated on the notification pipeline like v3-44/45.

Refs: v3-20 (manual yield), v3-44/45 (notification system), 17-changelog v3-49.

v3-48 — BE-FE gap audit: fund-member edit + NAV propose wired; retry-lp-mint removed ✅ Decided · 🛠 FE done

v3-48 — BE-FE Gap Audit & Closure

Date: 2026-06-26

Context: Follow-up to v3-47 (admin-user edit/delete gap) — a full audit of apps/admin-web for the same defect class (BE capability built, FE UI missing / unreachable / stubbed). Method: grep every API client fn + hook against the component layers to confirm true zero-usage (not assumed).

Closed (pure-FE — handler + hook already existed, only the CTA was missing):

  • Fund member editPATCH /fund-members/{id} + useUpdateFundMember were unused; fund-detail.tsx had only list + Remove. Added a per-row Edit modal (name / wallet / primary). Authority follows the existing member-management gate (same as Remove FM).
  • NAV manual proposePOST /nav-changes + useProposeNavChange were unused; the panel only had Activate/Cancel on oracle-created rows. Added a "Propose NAV Change" button + form (source: admin_override); a decrease shows the 24h-timelock notice. Gated by the yield page permission (handler-enforced).

Resolved — retry-lp-mint removed (option b):

  • deposits.tsx / dashboard.tsx "Retry LP Mint" / "Bulk Retry LP" buttons called POST /deposits/{id}/retry-lp-mint, which has no handler (would 404). Decision: removed the retry UI + retryLpMint client fn + useRetryLpMint hook. Rationale: in the non-custodial v3 model LP is minted inside the investor's own deposit() tx (atomic, v3-03) — Aset holds no key to re-mint, so an admin "retry LP mint" is architecturally impossible, not merely unbuilt. Contrast the SBT mint, which Aset's key issues → admin "Retry SBT" is real and stays (kyc.post.mint-sbt + FIFO worker). Failed deposits stay visible (Error Details + dashboard "Failed Deposits" item) but the CTA is downgraded to View (matching Failed Redemptions/Yield). The route/handler was never built and isn't needed.

Also flagged (unchanged): needs-BE stubs (admin notification prefs · yield distribute/retry/notify · pool transfer · platform config · send-test-notification — all correctly labeled disabled) and dead code (triggerAutoRedemption orphaned both ends; createAdminUser/createFundMember unused alternate paths). All tracked in the FE-UI-v3 "🔎 BE-FE 갭 감사" section.

Refs: v3-47, 09-rbac, 15-api-reference, 17-changelog v3-48. Tracker: FE-UI-v3 (🔎 BE-FE 갭 감사 2026-06-26).

v3-47 — Admin user management: edit name + delete reachable for all roles (authority = delete rule) ✅ Decided · 🛠 FE done

v3-47 — Admin User Edit (Name) + Delete Reachability

Date: 2026-06-26

Context: The admin-settings "Admin Users" panel (FE-UI #13 / A-9, marked done) shipped Operator permission editing but had two gaps QA hit: (1) no name-edit UI anywhere — updateAdminUser (PATCH /admin-users/{id}) and the useUpdateAdminUser hook existed but were never wired, so an invited admin with a blank name could not be fixed; (2) Delete was only reachable for Operators — the Delete button lived inside the permission modal, which only opened via the Operator-only "Permissions" button, so ADMIN / SUPER_ADMIN / FUND_MANAGER rows (showing static "Full Access") could be neither edited nor deleted. The gap was not separately enumerated in the FE-UI-v3 tracker (#13/A-9 was considered complete).

Decision (option A — authority mirrors delete): Every row opens a "Manage User" modal. Name-edit authority == delete authority (canDeleteUser): Admin may edit/delete Operator + FM; only Super Admin may edit/delete an Admin; no one may edit/delete a Super Admin. Name is an editable input (with inline Save) when authorized, read-only text otherwise. Operator permission toggles + Delete live in the same modal. Backend already enforced this via withRole + the existing PATCH/DELETE handlers — this closes the FE gap and the matrix omission.

Why A (not "anyone edits their own name"): consistency with the existing remove-authority rows (Invite/remove Admin = Super Admin; Operator/FM = Admin+) — one predicate, no special self-edit path.

Refs: 09-rbac → Permission Matrix, 15-api-reference → Admin Users, 17-changelog v3-47. Tracker: FE-UI-v3 (#13/A-9 follow-up).

Revised (2026-06-29): authority relaxed per PM — an ADMIN may now edit-name + delete another ADMIN (previously Super-Admin-only). SUPER_ADMIN stays protected (never deletable, editable only by a Super Admin) and the last-Admin guard remains. ⚠️ Correction: the original entry claimed the BE already enforced this, but DELETE / PATCH /admin-users/{id} were withRole('SUPER_ADMIN') with no target-role check — so an ADMIN was 403'd. Now withRole('SUPER_ADMIN','ADMIN') + server-side target guards (block SUPER_ADMIN target, block granting the SUPER_ADMIN role, last-Admin guard) so the relaxed FE rule cannot be bypassed via the API.

v3-46 — Redemption model (instant vs epoch) is a gating-structure choice, orthogonal to maturity ✅ Decided (clarification)

v3-46 — Instant vs Epoch is a Gating Choice, Not a Maturity Property

Date: 2026-06-26

Context: v3-26 epoch framed epoch as the REVOLVING model and instant as the FIXED_TERM model, which reads as "epoch exists because REVOLVING has no maturity." But FIXED_TERM pools also take pre-maturity early exits, and those hit the same concurrent-exit failure modes (FIFO unfairness, request-time NAV arbitrage, indefinite PENDING_RESERVE). Design-review question: if a fixed-term pool can be run on, why is epoch tied to REVOLVING?

Decision (clarification — no schema/code change): redemption_epoch_days is orthogonal to maturity_model, with no validation coupling between them (pools.post.create bounds it to 0–90 only; nothing forbids FIXED_TERM + > 0). The type-driven default (FIXED_TERM → 0, REVOLVING → epoch) is a creation-time prefill, not a constraint — a FIXED_TERM pool may be created with redemption_epoch_days > 0. The real selection axis is run risk — "can concurrent exit demand exceed available liquidity such that timing/order is unfair?" — not whether the pool has a maturity date.

  • REVOLVING: all exit volume is off-schedule and continuously exposed → epoch baseline.
  • FIXED_TERM: exit demand concentrates at the planned maturity (partner returns capital as loans mature); the pre-maturity early-exit tail is sparse and penalty-gated → instant + PENDING_RESERVE suffices. Opt into epoch at creation only for concentrated / run-prone pools or material writedown risk.

Scope / limits: (1) still immutable while live (v3-38) — the model is chosen at creation; no live instant↔epoch switch in v1. (2) Epoch makes exits fair and orderly under stress; it does not stop a run — insufficient liquidity still yields a pro-rata partial fill. Halting is is_paused / IMPAIRED / is_emergency_frozen; bleed-rate capping is redemption_gating_pct (epoch-only today; instant = future option). A per-pool live instant→epoch stress switch would need the v3-38 live-change governance path (v2, audit-gated).

Refs: v3-26 epoch, v3-38, 07 → Why epoch instead of instant, 04 → redemption_epoch_days, 17-changelog v3-46.

v3-45 — Investor email registration + verification (notification email channel) ✅ Decided · ✅ Built · ⚖️ legal follow-up

v3-45 — Investor Email Capture + Verification

Date: 2026-06-25

Context: Investors log in by wallet (SIWE), so users.email is a synthetic placeholder (<address>@wallet.aset.io) set at signup — not deliverable. The notification email channel therefore did nothing for investors (admin/FM have real emails from invite). But the v3-44 decision locks material notices (NAV writedown / impairment / wind-down) as legally-required to send, and in-app alone does not satisfy a legal "notice" obligation — it needs a real, deliverable channel. So investor email is a compliance requirement, not a nicety.

Decision: Add an investor email registration + verification flow.

  • POST /users/me/email stores a pending_email + single-use token (24h) and emails a verify link via SES; POST /auth/verify-email (public, token-gated) promotes it to the active email + stamps email_verified_at. Migration 0033.
  • A new GET /users/me investor self-profile endpoint backs the Settings UI (the FE had been calling it, but only the admin-only /users/{id} existed and rejected me).
  • The send worker skips synthetic @wallet.aset.io addresses — investor email fires only once a real address is verified; the in-app row is always delivered regardless.

⚖️ Legal follow-ups (open, bundle with material-notice threshold/timing): (1) must an investor have a verified email before investing (to guarantee material-notice reachability)? (2) does a material notice require provable delivery (SES delivery/bounce events via SNS → reflect true delivery, not just "SES accepted")?

Interim posture: until email capture is adopted by investors, investor material notices are in-app-only — not sufficient as legal notice; do not rely on them as the notice of record.

Refs: v3-44, 11-db-schema → users, 17-changelog v3-45. Notion: Notification — UX/Design (§📧 투자자 이메일 채널).

v3-44 — Notification system: code copy registry + Direction-B email template + payload jsonb schema ✅ Decided · 🛠 BE build (in progress)

v3-44 — Notification Implementation: Copy Registry + Template + payload Schema

Date: 2026-06-24

Context: The Notification PRD (Notion) reconciled three inconsistent SoTs — docs 13 Notification Event Matrix ↔ actual code producers ↔ FE preferences UI — and confirmed D1–D8 + channels (Email + in-app V1, Slack V2) + sender (no-reply@aset.finance) + unsubscribe-by-legal (V1 all transactional → exempt). Implementation needed two structural decisions before producers could be wired.

Decisions:

  • Copy lives in code, not the DB. A typed registry (lib/shared/notifications/copy.ts, 28 events) is the code mirror of the Notification Copy sheet; each entry carries both channels (in-app title/summary + email subject/preheader/hero/body/detail/CTA) + deep-link path + variable list. EN-only V1, i18n-structured.
  • One email template, slot-filled (Direction B). lib/shared/email/notification-email.ts renders all events from per-event copy: severity accent bar (info #6999fa / critical #ef5a3c), hero (amount for money events / status word for status events), 2-cell detail card, brand-purple CTA. Logo = hosted EMAIL_LOGO_URL PNG (Gmail blocks inline SVG).
  • Structured in-app via notification_logs.payload (jsonb), additive. Rather than new typed columns, one payload jsonb holds in_app (feed render slots), email (send-ready subject/html/text for the SES worker), and variables. read_at (+ partial index) backs the unread badge. Migration 0032. Producers set related_entity_type/id so FE deep-links resolve.

Build status: copy + renderer + producer helper (notify.ts) + migration done (tsc clean); first producer wired (indexer freeze.ts → pool_frozen/unfrozen/freeze_extended). Open: remaining producers, SES send worker (real send + retry/backoff + throttle), in-app read endpoints (GET /notifications etc.), (c) preferences save + POOL/ACCOUNT FE categories. Phase-0 SES domain verify + production access = BE dependency.

Refs: 11-db-schema → notification_logs, 17-changelog v3-44, v3-41 (audit feed — sibling event source). Notion: Notification — UX/Design (Draft) + Notification Copy sheet.

v3-43 — Pool field editability: phase-aware (DRAFT vs ACTIVE); capacity raise-only, min_investment locked ✅ Decided · 🛠 BE + FE build

v3-43 — Pool Field Editability: Phase-Aware + capacity/min_investment Classification

Date: 2026-06-24

Context: The on-chain vs DB boundary was set (v3-18), and editability was in fact already specified — 08 → Field Sync (v3-29) Class A/B/C + 04 → Editability After ACTIVE. But two gaps surfaced while triaging the Admin Pool Create — Draft save/edit bug (Notion QA): (1) the v3-29 table mislabeled capacity and min_investment as Class B "on-chain immutable", contradicting the same doc's on-chain state table (which does not include them) and the DB-only table (which lists them as Lambda-enforced soft limits); (2) the Class A row only enumerated is_paused, hiding the other on-chain-mutable fields in prose.

Decision:

  • capacity and min_investment are off-chain (Lambda-enforced at deposit gating; no on-chain counterpart). They are not Class B / not contract-immutable.
  • After ACTIVE: min_investment is locked (PATCH rejected — it's a published investor term; to change, deploy a new pool). capacity is raise-only — PATCH accepted only when new_capacity >= current_capacity (raising admits more investors with no harm to existing holders; lowering is rejected). Both enforced off-chain — no redeploy.
  • Class A enumerated: on-chain-mutable = is_paused/is_emergency_frozen (instant), reserve_percentage/fund_wallet/kyc_level_required/kyc_jurisdiction_whitelist (7-day timelock governance), nav_per_token (/nav-changes), lifecycle_status (/lifecycle). All via dedicated endpoints, never via PATCH.

Editability = (storage location) × (lifecycle phase). In DRAFT nothing is deployed → every field is a DB row and freely editable (incl. lockup_days/maturity_days/penalty_type). Immutability binds at the DRAFT → ACTIVE transition (deploy), not before.

Resolves the Pool Edit bug (Notion QA, was Blocked): it was not blocked on a missing decision — the spec existed; the Edit UI just didn't implement it. The Edit form must be phase-aware: in DRAFT show all fields editable (incl. the Maturity (days) field — Bug 2); after ACTIVE render Class B / min_investment as read-only with a "locked after publish — deploy a new pool to change" reason (don't hide them — Bug 2 Expected), Class A with a "needs multi-sig / 7-day timelock" notice, and capacity as raise-only.

Build: (BE) pools.patch.update.ts — keep min_investment rejected on deployed pools; add capacity raise-only guard (reject new < current). (FE) admin pool-edit form renders per-phase editability + lock reasons.

Refs: 08 → Field Sync (v3-29 corrected), 04 → Editability After ACTIVE, v3-18, v3-29, v3-38. Notion: Admin Pool Create — Draft save/edit (QA).

v3-42 — Fund Manager activity access: fund-scoped operational view; compliance Audit Log stays admin-only ✅ Decided · ✅ Built

v3-42 — FM Activity Access (fund-scoped operational; compliance admin-only)

Date: 2026-06-24

Context: The Dashboard "Recent Activity (48h)" widget and the Audit Log page are the same GET /activity-events endpoint, just different time windows. So "FM gets the dashboard activity but not the audit log" wasn't a coherent split — it's one endpoint, and the real question was whether to fund-scope it for FM. 09-rbac contradicted itself: L227 said FM gets fund-scoped /activity-events, while L184/L231/L167/L104 said the Audit Log is admin-only (FM 403). Research (multi-tenant SaaS norm = tenant-scoped audit; fund-admin tools = GP sees own-fund trail) says scope-and-show is standard — but only when the role is the data controller. Aset's FM is explicitly a processor, no independent KYC/AML duty (L206), so the compliance audit log (KYC results, PII-access, platform-wide admin actions) is the controller's record, not the FM's.

Decision — split by what, not by page:

  • Operational activity → FM sees it, fund-scoped. Deposits / redemptions / yield / NAV + pool admin actions on the FM's own pools. Implemented by filtering audit_feed to related_entity_type = 'pool' AND pool ∈ the FM's funds' pools. Compliance events (PII_ACCESS, KYC_*) are user-targeted, so they fall out automatically — no separate exclusion list needed.
  • Compliance Audit Log → admin-only. Full cross-platform feed + CSV export stay ADMIN/SUPER_ADMIN (export is the controller's tool).
  • Same route, role-adaptive chrome. /audit-log opens to FM via a new activity PageKey, but renders as "Activity" for FM (title, sidebar label) vs "Audit Log" for admins; Export hidden for FM. This reconciles the doc contradiction: L227 = endpoint data scoping (correct), L184/L231 = full-compliance-page access (still admin-only in spirit; FM's view is the scoped Activity, not the compliance log).

Resolves: the FM Recent-Activity widget was returning [] (scoping unimplemented, L161). Now it shows the FM's fund activity, and "View all activity →" leads to the scoped Activity page.

Build (done, jy/product): get.list FM branch fund-scopes via getFundManagerFundIds/getFundPoolIds; activity PageKey + FM grant; /audit-log route guard → page/activity; sidebar role-label; dashboard canViewAuditLog includes FM; audit-log page role-adaptive (title / export-hidden / footer). No migration (pool rows already carry related_entity_id = pool_id).

Refs: v3-41, 09-rbac → FM panel scoping, activity-events.get.list.ts, audit-log.tsx, sidebar.tsx

v3-41 — Audit log = unified two-stream feed (human actions + on-chain mirror); 5y retention ✅ Decided · 🔧 BE build

v3-41 — Activity / Audit Log: Two-Stream Unified Feed

Date: 2026-06-23

Context: 13-operations defines activity_events as the "single audit source for V1" (48h dashboard + retention + admin-only CSV). In reality the table, GET /activity-events API, and audit-log.tsx UI all exist, but the only writer records PII_ACCESS (an admin opening an investor's PII sidebar). Meanwhile the on-chain indexer (onchain-indexer.scheduler) already mirrors every contract event — Deposited, Redemption*, Yield*, Nav*, EmergencyFrozen/Unfrozen, LP Transfer — into dedicated tables (deposits, redemption_requests, yield_distributions, nav_history, redemption_epochs, portfolio_positions) with tx_hash/log_index provenance. So the audit data exists; what's missing is (a) a unified viewing window and (b) who-did-it attribution for privileged human actions.

Decision — the audit feed has two streams, joined at read time (not a second copy):

  1. On-chain events → READ from existing indexer tables (UNION view). The indexer is already the mirror; the audit-log API surfaces those rows. actor = system/wallet (service key, multisig). Do NOT re-INSERT them into activity_events — that would double-write and create a second SoT.
  2. Human privileged actions → WRITE to activity_events at the admin-web → API boundary, capturing actor_id from the verified JWT (same pattern PII_ACCESS already uses). These are the discretionary human acts the chain can't attribute: KYC_APPROVE/KYC_REVOKE, FREEZE/UNFREEZE/PAUSE, IMPAIR_* / WINDDOWN_* (propose/execute), REDEMPTION_APPROVE/REJECT/HOLD/RELEASE, FEE_WITHDRAW, governance proposals (fund_wallet/reserve/treasury), PII_ACCESS (done).

Retention = 5 years (was "2 years"). Basis: SG MAS Notice PSN02 (DPT service provider — customer/transaction records ≥5y) and TH AMLA §22 (≥5y; first 2y readily available). MY is 6–7y per SC guidelines (legal to confirm if MY entity applies). The "2 years" figure was the readily-available / hot-export window, not the retention floor. → archive job retains 5y; dashboard hot window unchanged (48h default, date-range export).

Mainnet blocker? No (code-verified). All lifecycle events are emitted on-chain and persisted by the indexer with on-chain provenance → the audit trail already exists; the gap is consolidation (UX) + human-actor attribution. Priority: P1 (non-blocking) for the unified read-view + CSV/archive; P0-soft (recommended pre-mainnet) for the human-actor writers on privileged actions, since AML audits expect "which staff member did this."

Build:

  1. (BE, P0-soft) add activity_events writers at admin API boundaries for the privileged-action list above (actor_id from JWT).
  2. (BE, P1) GET /activity-events returns a UNION of activity_events + on-chain indexer tables (normalized to the ActivityEventView shape).
  3. (BE, P1) admin-only CSV export endpoint (UI export exists; needs server side for full-period pulls).
  4. (BE, P1) retention/archive job — 5y (not 2y).
  5. (FE) audit-log.tsx already exists → renders once the API returns the merged feed.

Refs: 13-operations → Audit Log, Activity/Audit gap page (Notion), apps/infra/lib/shared/indexer/, activity-events.post.create.ts, admin-web/app/routes/audit-log.tsx

v3-40 — Preview (marketing/showcase) pool tier + register-interest CTA ✅ Decided · 🛠 FE build

v3-40 — Preview Pool Tier (showcase) + Register-Interest CTA

Date: 2026-06-23

Context: Partners (e.g. Joob/FJL) run other funds that won't take investment through Aset, but we want to showcase them on-platform — "look how many deals run here" — to drive interest in future rounds (e.g. FJL 5). Neither existing status fits: UPCOMING means "opening here soon (with a date)," CLOSED means "was open here, enrollment ended" — and CLOSED currently still renders full live performance (fund-data / NAV / DPD), which we do not want for a showcase. (Source: 6/23 Grab×JOOB wrap-up, Option B "pure on-boarding / API 연동, 비용 X" — minimal consent: size/structure high-level, no current status.) Refines the earlier "display-only pool" item (FE-UI #19), which had been "just hide them."

Decision: Add a preview pool tier (flag/status) — visible but never investable on Aset:

  • Shows Overview only (high-level: name, manager, asset class, maturity, deal structure / target yield). Performance + Fund-Data hidden (no live NAV / fund value / DPD / status).
  • Register-interest CTA ("notify me") that sends a lead notification to Aset — for the next round of the same fund. This CTA also appears on CLOSED pools (same fund may re-run). Ties into the Notification track.
  • Spec at the tab level, not the field level: the rule is "preview → Overview tab only" — the FE may change, so don't hardcode a field list.

Display tiers → partner API needed (drives the Joob data-request sheet):

TierShowsAPI needed
Preview (showcase)Overview only (static profile / deal structure) + register-interest CTAstatic profile only — request broadly as Required from the partner (even if exact Overview fields aren't fixed). No ongoing value/yield, no risk/time-series.
Display (open, basic)+ current fund value · NAV · APY · status1st data request (ongoing display fields)
Full (open, risk badge)+ DPD / concentration / tenor + time-series2nd data request (risk) + time-series

Why a new tier (not reuse CLOSED/UPCOMING): semantics differ (external, never-investable here, register interest) and CLOSED currently exposes full performance.

Implementation (FE, no contract impact): preview flag/status → render Overview only, hide Performance/Fund-Data tabs, no invest sidebar, register-interest CTA (preview + closed). Tracked in FE-UI #19 (Notion).

Refs: 6/23 Grab×JOOB wrap-up (Option B), v3-12 lifecycle, v3-29 (is_paused / visibility), 04-pool-models → lifecycle_status, 10-status-machines. Notion: Joob data-request sheet (tier-based Required), FE-UI-v3 #19.

v3-39 — Tranche first-loss = v1 off-chain (Lambda) trust model; on-chain waterfall deferred ✅ Decided · v1, no contract change

v3-39 — Tranche First-Loss: Trust Model (v1), On-Chain Waterfall Deferred

Date: 2026-06-23

Context: For tranched products (v3-14), the loss/yield waterfall is applied off-chain by Aset oracle Lambda — each tranche is a separate SINGLE pool ("smart contract impact: zero"); Lambda marks the Junior pool's NAV down first, then calls updateNAV() on each pool (04 → Tranche Group Waterfall). So a Senior investor relies on Aset to apply subordination correctly — a trust model, not contract-enforced. Question: accept for launch, or build trustless on-chain subordination first?

Decision (v1): Keep the trust model (off-chain Lambda waterfall, per v3-14).

  • Junior = partner-provided first-loss capital, deployed as its own Junior pool, gated from retail — not surfaced in the investor app + KYB/whitelist deposit (FJL / LFC provides Junior; investors take Senior). Hard on-chain deposit restriction = a deposit allowlist (separate item); v1 relies on UI-hide + KYB gating.
  • First-loss thickness = per-deal / per-pool config (set at pool creation with each partner), not a platform constant.

Why acceptable: valuation is off-chain industry-wide (Centrifuge V2/V3 push NAV via Chronicle oracle; Maple / Goldfinch / Huma use a manager/agent) — on-chain subordination is an optional sophistication some add later (TrueFi: original pools had no tranche → newer Credit Vaults added an on-chain A/B/C waterfall). The trust is bounded: v3-32 deviation cap / circuit breaker / staleness limit how far Lambda can move any NAV, and money-path immutability (v3-27) means the worst case is mis-marking, not theft.

Mixed-capital note: when our pool is only a slice of a larger fund (external co-investors off our chain), a fund-level "% of total" buffer cannot be enforced on-chain from our contract anyway (no visibility into the total denominator). Lambda applies the waterfall to our Senior/Junior pools only — which is exactly what the trust model does.

Future option (non-destructive): if institutional Senior investors require trustless subordination, add an on-chain waterfall pool type (Centrifuge/TrueFi-style single-pool-2-token, or cross-pool cascade) deployed alongside — no migration of trust-model pools (tranches are separate pools + money-path immutable). Trigger: institutional demand or scale.

Refs: v3-14 (tranche structure), v3-15 (Lambda = sole NAV), v3-32 (NAV guards), v3-27 (money-path immutable), 04 → Tranche Group. Benchmarks: Centrifuge Tinlake vs V2/V3, TrueFi Credit Vaults, Maple pool cover.

v3-37 — Adopt Gnosis Safe multisig for cold roles (Admin 3-of-5 + separate Pauser 2-of-3) ✅ Decided · ⚙️ Config, no contract change

v3-37 — Cold-Key Multisig via Gnosis Safe (Admin / Pauser split)

Date: 2026-06-23

Context: Non-custody never required multisig — an immutable money-path means even a single cold key cannot redirect funds (v3-32), so multisig was kept only as optional defense-in-depth. Following the 2026-06-23 external security review, we adopt it: single-person key control is a scaling risk, and Gnosis Safe (ERC standard) is the answer.

Decision: Move the two human-controlled cold roles onto Gnosis Safe multisig wallets — no contract change (each role is already a distinct address-held role; we just grant it to a Safe, and the contract only checks hasRole, indifferent to EOA vs Safe). Split the two cold roles onto separate Safes with different thresholds:

  • DEFAULT_ADMIN_ROLE (governance / lifecycle / role-grant) → cold Safe, 3-of-5. High-impact, rare, and already timelocked (7d/30d), so a higher threshold costs nothing operationally. Also closes the "single cold key can self-grant roles" concern.
  • PAUSER_ROLE (pause / emergency freeze) → a separate cold Safe, 2-of-3. Emergency freeze must act fast; the lower threshold is the speed/safety trade. Safe because freeze is halt-only and auto-expiring (v3-28: exits unblock at 72h, whole freeze expires at 7d) → a compromised Pauser can only grief (DoS), never steal.
  • ORACLE_ROLE stays the hot Lambda key (unattended automation; bounded by fixed destinations + v3-32 NAV bounds). Requiring a cold sig here would break automation.
  • YIELD_DEPOSITOR_ROLE stays the partner fund_wallet Safe (self-custody).

Thresholds (provisional): 3-of-5 / 2-of-3 as a starting point; may be revised later (cost / signer availability). Signers physically separated across team members (avoid single point of failure / 1-of-1 misconfig).

No new role / no redeploy: Admin and Pauser are already separate on-chain roles, so splitting them across two Safes is configuration only. (A genuinely new signer-combination tier — e.g. "1 cold + 1 hot" for specific ops — would need a new role + redeploy; explicitly not doing that.) Wiring: pass the Safe addresses at pool deploy, or post-deploy grantRole(PAUSER, pauserSafe) + renounceRole(PAUSER, adminSafe) (Admin is PAUSER's role-admin).

Refs: v3-32 (no-multisig-required baseline), v3-28 (time-bound freeze → bounds Pauser risk), 09a-custody → Key model, 09-rbac → Key custody, Security review meeting 2026-06-23 (Notion). Audit follow-up: Zellic Korea.

v3-38 — Epoch duration is set at pool creation only; live change deferred to v2 timelock governance ✅ Decided · 🛠 FE + deploy wiring

v3-38 — Epoch Duration: Create-Only (v1), Timelocked Governance (v2)

Date: 2026-06-23

Context: The epoch contract refactor shipped setEpochDurationDays(uint256) as a naked DEFAULT_ADMIN_ROLE setter — no max bound, no event, no timelock, no create-only/live guard (s.epochDurationDays = newDurationDays;, PlatformPool.sol). But epoch_days directly controls when and how investors exit — a more investor-sensitive lever than reserve_percentage (which is 7-day timelocked, v3-18) — so a bare live setter is inconsistent with the money-path-immutable / time-bound-control philosophy (v3-27–v3-33, v3-28, v3-31).

Decision (v1): epoch_days is set only at pool creation — folded into the deploy multicall (createPoolOnChainsetEpochDurationDays), type-prefilled (REVOLVING → 7, FIXED_TERM → 0). No live-change UI is exposed, and the contract enforces create-only at the trust boundary — setEpochDurationDays reverts once the pool has investors (totalLPSupply > 0), so it is settable only at deploy (pre-deposit; see hardening). Because no existing investor's redemption terms ever change after deposit, the timelock / custody / disclosure problem is sidestepped entirely — consistent with "money-path immutable."

Why create-only is the safe cut:

  • Tightening liquidity on a live pool (instant→epoch, or longer N) = an undisclosed redemption gate → investor-protection / regulatory red flag.
  • Unbounded N = a fund-trapping backdoor (admin could set 36500d) → breaks the non-custodial exit guarantee (v3-31) and the time-bound-freeze principle (v3-28).
  • Investors deposit knowing the pool's redemption model; it never moves under them.

Contract hardening — ✅ implemented & tested (jy/product; pending mainnet deploy + Zellic audit): setEpochDurationDays now (a) enforces a max bound MAX_EPOCH_DURATION_DAYS = 90 (revert EpochDurationTooLong) so it can never trap funds, (b) emits EpochDurationChanged(oldDays, newDays) for indexer/monitoring, (c) enforces create-only on-chain — reverts EpochImmutableAfterDeposit once the pool has investors (totalLPSupply > 0); the deploy multicall sets it pre-deposit (LP = 0) so it passes, then it locks. Stronger than a mid-epoch / queued-demand guard: those only protect during redemption activity, but the term must not change for any existing investor — so the trigger is "has investors," not "has pending redemptions," making "immutable while live" a contract-enforced guarantee. Covered by PlatformPoolEpoch.t.sol (test_SetEpochDuration_RevertsAboveMax / _EmitsEventWhileEmpty / _RevertsAfterFirstDeposit / _AllowsMaxBoundaryWhileEmpty; full epoch suite 23/23 green). ⚠️ Sepolia (deployed 2026-06-22) is the pre-hardening build → needs redeploy. (GitHub #6, closed — handoff via MD/Slack.)

v2 (post-launch, needs audit): if live change is ever required, route setEpochDurationDays through the same 7-day timelock governance as fund_wallet / reserve% / KYC (propose→execute→cancel), direction-aware: tightening = full notice + an exit window under the old terms; loosening (→instant, shorter N) = lower notice. This is a contract change → audit cycle, explicitly not in v1.

FE impact: live-immutable config fields — epoch_days now joins the Class-B set (capacity, min/max investment, penalty, redemption_type, lockup/maturity, currencies) — must render in admin pool-edit as locked + a brief reason ("set at creation — changing redemption terms on live investors requires a new pool"). Tracked in FE-UI-v3 (Notion).

Refs: v3-26 epoch redemption, 04 → redemption_epoch_days, 07 → Epoch-Based Redemption, 08 → Field Sync, v3-18, v3-28, v3-31, v3-32. Notion: Epoch UX/Design v3-26 §3, FE-UI-v3 tracker.

v3-35 — Drop collateral_type enum (completes v3-07) ✅ Decided

v3-35 — Remove collateral_type enum

Date: 2026-06-18

Decision: Fully remove the collateral_type enum (FULLY_COLLATERALIZED / PARTIALLY_COLLATERALIZED / UNSECURED), completing v3-07 (which added collateral_description but left the enum live + half-migrated). Collateral is modeled by collateral_description (free text) + collateral_ratio (NUMERIC, display-only). collateral_ratio is display-only metadata, not sent on-chain, and may exceed 100% (over-collateralized, e.g. 150%) — no Fully/Partially/Unsecured consistency is enforced.

Why: v3-25 made the collateraltype badge obsolete (risk signal → auto-computed Risk Tier). With collateral no longer a risk input and not on-chain, the rigid 3-bucket enum (which real collateral arrangements don't fit — v3-07) plus its enum↔ratio validation add no value. This **obsoletes _Pool Create Step 4 Bug 3/4** (strict Partially=0–100% / Unsecured=0 enforcement) — those were blocked precisely on this model question.

Cleanup scope: drop collateral_type enum + column (migration) with collateral_description backfill · admin pool-create/edit select → description input · pool list/detail CollateralBadge/CollateralLabel → description text (or drop; Risk Tier carries the signal) · remove VALID_COLLATERAL_TYPES API check · sync 02-core-concepts / 04-pool-models / 11-db-schema / CLAUDE.md. (FE-UI #32 + DB migration)

Refs: v3-07 (enum→description), v3-25 (Risk Tier badge), Pool Risk Tier Decision Log (Notion), FE-UI #32

v3-11 — Absorb PlatformEscrow into PlatformPool (5 → 4 contracts) ✅ Decided

v3-11 — PlatformEscrow Removal

Date: 2026-06-02

Decision: Delete PlatformEscrow.sol. Move the 10/90 deposit split (reserve / fund_wallet) into PlatformPool.deposit() directly. v3.0 contract count: 4 active (down from 6 in v2.x).

Why: With Receipt NFT removed (v3-03) and deposit atomic, Escrow's only remaining role is a thin 10/90 splitter — 5 lines of inline code in the Pool. Keeping a separate contract adds:

  • One extra external call (gas + complexity)
  • One more contract to audit
  • A separate escrow_address to track in Lambda/Frontend ABIs
  • No actual separation-of-concerns benefit (deposit is atomic anyway)

"Future MULTI_SIG escrow" is achievable by making PlatformPool admin a multi-sig wallet — same outcome, fewer moving parts.

Backend dev work:

  • Delete apps/contract/src/PlatformEscrow.sol
  • Inline split logic into PlatformPool.deposit()
  • Remove escrow_address from PoolConfig + Factory deploy
  • Drop escrow_address references in Lambda handlers and frontend (DB column stays as deprecated)

Refs: Smart Contracts → Contract Registry, v3.0 Handoff Spec

v3-12 — Unify wind-down with NAV mechanism + add IMPAIRED state ✅ Decided

v3-12 — Loss & Termination Lifecycle Unification

Date: 2026-06-02

Decision: Merge wind-down distribution into the standard NAV/redeem mechanism. Add IMPAIRED lifecycle state between ACTIVE and WIND_DOWN (Maple-pattern impairment).

Lifecycle flow:

HEALTHY → WRITEDOWN (NAV < 1.0) → IMPAIRED (deposits paused, redemptions open) → WIND_DOWN (terminal) → TERMINATED

Mechanism changes:

  • PlatformPool.claimWindDown() removed — standard redeem() handles all paths
  • executeWindDown() now sets navPerToken = reserveBalance / totalSupply (immediate, no 24h timelock since 30-day timelock already elapsed)
  • redeem() waives lockup/penalty when lifecycle_status is WIND_DOWN or IMPAIRED
  • New IMPAIRED state: deposits paused, redemptions allowed with current NAV, lockup waived. Triggered by admin propose (7-day timelock) when partner shows distress signs
  • DB: lifecycle_status enum gains IMPAIRED value

Why: Mathematically (LP/totalLP) × reserve equals LP × NAV when NAV = reserve / totalSupply. Two functions doing the same math is redundant. Centrifuge unifies similarly; Maple adds the IMPAIRED intermediate.

Why IMPAIRED matters: Partner distress is often gradual (delayed NAV updates, late yield, DPD spike). Hard-jumping ACTIVE → WIND_DOWN forces a binary choice. IMPAIRED lets us pause new deposits while keeping the pool open for existing holders to redeem — without committing to terminal wind-down yet.

Refs: Status Machines, Pool Models → Lifecycle, Writedown & NAV

v3-13 — Automated NAV writedown via Joob DPD API (OJK schedule) ✅ Decided

v3-13 — DPD-Based Auto-Writedown

Date: 2026-06-02

⚠️ Amended by R5 · R5.5 (v3-109). Two things changed. (1) Delinquency alone never moves NAV — only a realized write-off does, so the OJK ladder below is a deferred automation template, not today's input; the live input is the partner's reported cumulative loss. (2) The automation is not autonomous: a computed NAV is a proposal an admin approves, and a computed wipeout routes to proposeImpairment instead of applying. The reserveConsumed this card once fed is now always 0 (R8). Kept for the schedule itself — do not implement the auto-apply loop from this card.

Decision: Aset Lambda ingests Joob's per-loan DPD data via API, applies a per-loan write-off schedule, aggregates into pool-level NAV, and calls Pool.updateNAV() automatically (24h timelock as usual).

Initial schedule — OJK (Indonesian regulator) standard, POJK No.40/2019:

DPD bucketOJK classWrite-off rate
1–90DPK (Special Mention)5%
91–120Kurang Lancar (Substandard)15%
121–180Diragukan (Doubtful)50%
181+Macet (Loss)100%

Why OJK first: Joob operates under OJK regulation in Indonesia. Their internal provisioning policy likely already matches this. Pending confirmation with Joob — if their policy differs, schedule will adjust.

Future generalization: Schedule will be promoted to a per-pool writeoff_schedule dimension (v3-13.1, deferred) so non-Indonesian pools can use IFRS 9 or custom schedules without code change.

Implementation:

  • New Lambda joob-dpd-sync — daily cron, calls Joob eNote API, computes aggregate writeoff, calls Pool.updateNAV(newNav)
  • New table loan_writeoffs — historical per-loan writeoff audit
  • NAV update reasons: JOOB_DPD_AUTO source code in nav_history.source

Refs: Writedown & NAV, Joob Pool Config


🟡 Business Decisions

These shape the product's financial and operational model. Each has a recommendation but is open for discussion.

BD1 — Yield Distribution Model ✅ Resolved

BD1 — Yield Distribution Model

Question: How should yield be delivered to investors?

→ Decision: Manual Claim (MANUAL_CLAIM)

Options considered:

  1. Distributing ★ — Yield paid separately as USDC or LP tokens; LP price stays fixed at $1; Clear separation between principal and yield
  2. Auto-Compound — Yield automatically reinvested into the pool; LP price appreciates to reflect accumulated returns
  3. Manual Claim — Yield accrues in contract; Investor must trigger a claim transaction to receive it

Blocks: 🟡 BD5 · Refs: Core Concepts, Investment Lifecycle, Triggers & Rules

BD2 — Early Redemption Policy Open

BD2 — Early Redemption Policy

Question: What happens when an investor exits before the lock-up period ends?

★ Recommended: Per-pool configurable penalty

Options:

  1. Per-pool configurable penalty ★ — Each pool sets its own early exit penalty (e.g., 50% of accrued yield); Flexible for different asset classes
  2. Flat platform-wide % — Same penalty rate across all pools; Simpler but inflexible for different asset durations
  3. No penalty — Investors can exit freely at any time; Risk: bank-run behavior during market stress
  4. Full lockup (no early exit) — Investors cannot redeem until maturity; Simplest to implement but worst UX

Blocks: 🟡 BD6 · Refs: Redemption, Triggers & Rules

BD3 — Token Issuance / Loss Model Open

BD3 — Token Issuance / Loss Model

Question: How should the platform reflect asset losses to investors?

★ Recommended: NAV Token Pricing

Options:

  1. NAV Token Pricing ★ — Token price floats $0.00–$1.00 (no floor); Investment stays open during loss; New investors get fair entry at current NAV price
  2. Principal Factor (PF) — Fixed $1 token price with separate PF multiplier; Investment auto-blocked when PF < 1.0

Blocks: 🟡 BD4 🔵 PD1 🔵 PD2 🔵 PD3 🔵 PD4 🔵 PD5 · Refs: Core Concepts, Writedown & NAV, Redemption, Database Schema, Triggers & Rules

BD4 — Reserve Fund Mechanics ⚠️ Superseded — v3-109

BD4 — Reserve Fund Mechanics

⚠️ Superseded by R8 (v3-109). The premise of this card — that the reserve is a first-loss layer that "covers losses before NAV drops" — is retired. The reserve is carved out of investor deposits, so it is already inside the claim NAV prices; netting it against a loss double-counted. Reserve = redemption liquidity buffer only. First-loss is the manager equity buffer (R6) for standalone pools and the Junior tranche for grouped pools. The 10% sizing decision itself still stands, as a liquidity target. Kept for history — do not implement from this card.

Question: How should the first-loss reserve be structured?

★ Recommended: 10% first-loss reserve per pool

Options:

  1. 10% first-loss reserve per pool ★ — 10% of deposits allocated to reserve; Covers losses before NAV drops; Configurable per pool at creation
  2. Per-pool configurable % — Each pool sets its own reserve rate; More flexibility, more operational complexity
  3. Platform-wide fixed % — Same rate for all pools; Simpler to manage
  4. Hybrid — Platform sets minimum reserve %; Pools can exceed but never go below

Depends on: 🟡 BD3 · Refs: Core Concepts

BD5 — Reinvestment Mechanism ✅ Resolved

BD5 — Reinvestment Mechanism

Question: What happens when an investment matures?

→ Decision: Manual Reinvest V1

Options considered:

  1. Auto-roll with opt-out ★ — At maturity, investment auto-renews; Investor can opt out within defined window
  2. Manual only — Investor must actively choose to reinvest; Funds return to wallet at maturity if no action
  3. Auto-roll (no opt-out) — Always reinvests; Investor must initiate redemption to exit

Depends on: 🟡 BD1 · Refs: Investment Lifecycle

BD6 — Lock-Up Period Structure Open

BD6 — Lock-Up Period Structure

Question: How should investment lock-up periods work?

★ Recommended: Per-pool configurable

Options:

  1. Per-pool configurable ★ — Each pool defines its own lock-up at creation; Matches real-world asset liquidity profiles
  2. Platform-wide standard — All pools use same lock-up (e.g., 90 days); Simpler but inflexible
  3. No lock-up — Investors can redeem anytime; Risk: liquidity mismatch with illiquid assets

Depends on: 🟡 BD2 · Refs: Redemption, Triggers & Rules

🔵 Product Decisions

Technical architecture choices. Some are blocked until a related Business Decision is resolved.

PD1 — NAV Oracle Contract ✅ Resolved

PD1 — NAV Oracle Contract

Question: Where does the NAV update logic live on-chain?

→ Decision: Integrated into PlatformPool (Feb 20, 2026)

Options considered:

  1. Integrated into PlatformPool ★ — Add updateNAV() + ORACLE_ROLE directly to PlatformPool; Simpler — fewer deployments, one contract
  2. Separate NAVOracle contract — Dedicated contract for NAV updates; Cleaner separation but more interactions
  3. Third-party oracle (Chainlink) — Use Chainlink custom data feed; Complex for private RWA NAV

Depends on: 🟡 BD3 · Blocks: 🔵 PD2 🔵 PD3 · Refs: Core Concepts, Smart Contracts, Writedown & NAV

PD2 — Oracle Role Mechanism ✅ Resolved

PD2 — Oracle Role Mechanism

Question: How does the oracle authenticate on-chain?

→ Decision: ORACLE_ROLE on PlatformPool (Feb 20, 2026)

Options considered:

  1. ORACLE_ROLE on PlatformPool ★ — Add ORACLE_ROLE to AccessControl; Only wallets with this role can call updateNAV()
  2. Separate oracle contract auth — Oracle contract holds permission; More isolation but more interactions
  3. Multi-sig oracle — Multiple signers required per update; Most secure but impractical for frequent updates

Depends on: 🔵 PD1 · Refs: Smart Contracts

PD3 — Oracle Operator ✅ Resolved

PD3 — Oracle Operator

Question: Who runs the oracle service?

→ Decision: Aset-operated (Feb 20, 2026)

Options considered:

  1. Aset-operated ★ — Aset runs the oracle; Fund operators report NAV, Aset validates and posts on-chain
  2. Third-party service — Outsource to an oracle provider; Less burden but additional trust assumptions
  3. Decentralized — Multiple independent NAV reporters; Most trustless but complex for V1

Depends on: 🔵 PD1 · Blocks: 🔵 PD4 · Refs: Core Concepts, Writedown & NAV

PD4 — Oracle Offline Fallback ✅ Resolved

PD4 — Oracle Offline Fallback

Question: What happens if the oracle stops posting updates?

→ Decision: No fallback (Feb 20, 2026)

Options considered:

  1. Admin manual override — Admin can post NAV updates manually; Logged as admin_override in nav_history
  2. Auto-pause pool — Pool auto-pauses after X hours without update; Safest but disruptive
  3. No fallback — NAV stays at last known value until oracle resumes; Admin can manually pause for serious cases

Depends on: 🔵 PD3 · Refs: Writedown & NAV, Admin & RBAC

PD5 — NAV Timelock Duration ✅ Resolved

PD5 — NAV Timelock Duration

Decision (2026-06-08): 24 hours for NAV decreases; increases apply immediately. Gives existing investors time to react before a write-down without making NAV stale.

Confirmed as part of the full timelock sign-off:

  • 24h — NAV decrease
  • 7 days — admin param changes (fund_wallet / reserve / KYC level / jurisdiction) + Impairment execution
  • 30 days — wind-down execution (after 60-day silence)
  • No timelock (instant) — emergency freeze

Values match the implemented contract constants and are conservative vs benchmarks (Compound 2d, MakerDAO GSM 16–30h).

Refs: Writedown & NAV, Smart Contracts → Multi-sig + Timelock

Regulatory and legal structure decisions. Require legal counsel before finalizing.

L7 — SPV Ownership Model Open
L8 — SPV Jurisdiction Open
L9 — Escrow & Custody Model Open