Skip to content

Changelog

Architecture decisions and doc updates over time, most recent first. 167 entries across nine months.

This page is the dated narrative: what changed, when, and what it broke. The decision it implements has its own card in 14-decisions — that card is the durable statement, this entry is the history.

Jump to: September 2026 · August 2026 · July 2026 · June 2026 · May 2026 · March 2026 · February 2026 · January 2026 · December 2025

September 2026 1 entry

📅 September 4, 2026 — The permissionless exit fallback is recorded as withdrawn, four days after the contract dropped it

📝 Docs only · zero code lines

v3-157claimRedemptionFallback is gone from PlatformPool (2026-08-31), and it was a guarantee rather than a function. It let anybody settle an instant-pool request 7 days after it was made, which is what made "an operator who stops answering cannot trap a holder on chain" a property of the contract rather than a promise. v3-155 routed the reserve out of the pool, so the balance the fallback paid from is structurally zero: it could not have rescued anyone, and keeping it would have left a guarantee that reverts.

The exit right now rests on the reserve wallet funding the request off-chain. A discretionary act by a keyholder replaces a permissionless one by anybody. Recorded as a trade, not as housekeeping.

🔴 Half of v3-31 survives. Epoch pools still have permissionless executeEpoch and claim-on-behalf, so a settled cycle pays out whether Aset acts or not. Only the instant leg went.

Two cards gained banners and neither was rewritten: v3-28 (its freeze half stands: the asymmetric gate, the 72h exit window, the 7d auto-expiry are all live) and v3-31 (half-implemented, not implemented).

🔴 The pass is deliberately incomplete and the card says where. Six pages still describe the fallback in the present tense, one of them inside 08a-contract-reference §3.5 Non-custodial guarantees — the worst possible place for it. Also outstanding: FALLBACK_NOTICE_DAYS still listed as a live constant, and two contract comments (PlatformKYCSoulbound.sol:75, PlatformPoolBase.sol:128). Listed rather than quietly left, because a withdrawn guarantee still written down somewhere is the exact failure being recorded here.

⚠️ approveRedemption went in the same removal for an unrelated reason and is not a withdrawn guarantee: it settled the rare case where the reserve had grown through new deposits since the request, and deposits no longer grow it, so the case is impossible rather than rare.

Source: CH contract round 2026-08-31. Recorded by JY 2026-09-04.

August 2026 45 entries

📅 August 30, 2026 — Seven product decisions from the 27th get their cards, and the reinvest rule the pages disagreed about

📝 Docs only · zero code lines

Seven decisions closed on 2026-08-27 and were written into the pages on the 29th. None of them has shipped. Each is recorded with its date and an explicit "not yet reflected", because the failure mode here is a page that reads as done and becomes the next reader's truth. The cards are v3-149 through v3-155.

  • v3-149 — instant redemption has no cancel; the epoch flow is unchanged and the on-chain branch stays
  • v3-150redemption_gating_bps off the surface, contract and column untouched
  • v3-151 — reinvest off every screen; allow_rollover's false default is what closes the path
  • v3-152executeWindDown stops recomputing NAV — new pools only, Clones are pinned
  • v3-153 — interest accrues on paid-in capital, and a write-down does not move the bill
  • v3-154apy_rate and accrual_rate_bps become exclusive by accrual mode
  • v3-155 — the reserve routes to an external wallet and the in-pool counter goes

🔴 Where a contract still carries the thing being dropped, the text says so. The wind-down and R10 sections are left standing: they remain exactly true for every pool already deployed, and those pools keep their implementation permanently.

Five corrections in the same pass, where the code was right and the page was stale: allow_rollover does have an on-chain twin (setAllowRollover); NAV derivation is not manual — runPricingSweep runs, and what is unbuilt is the DPD → OJK per-loan pipeline; the sweep's entry point is report.scheduler.derive, not the deleted nav-proposals.scheduler.suggest; nav-changes.post.propose.ts is a route shim and the logic lives in the usecase; and reserve_consumed is always 0 under R8 (schema comment only).

🔴 And a sixth, found afterwards: the two pages disagreed about partial reinvest. 02-core-concepts said "Full reinvest only (no partial)" while 05-investment-lifecycle said the opposite. The code settles it — YieldLib.reinvest rejects only an over-request, and its minimum-amount guard exists specifically for a partial below min_reinvest_amount, which is a guard with no object if only the full balance can be reinvested. 02 also wrote the call as reinvest(fullYield); the signature is reinvest(address stablecoin, uint256 yieldAmount). ⚠️ The BD5 entry of 2026-07 is left as written — it was true when BD5 was decided; v3-151 is what supersedes it.

TODO(N-1) left in 06-writedown-nav where the loss input is the partner's NPL rather than write-offs: observe.ts feeds totalNpl into the CUMULATIVE_LOSS metric, so NAV is priced off money that is late rather than money that is lost. The wording depends on a partner API request that is still out.

Source: JY, 2026-08-27 decision round. Docs pass 2026-08-29.

📅 August 27, 2026 — Three doc facts the code had already overtaken: a pool roster off by one, a decision reversed hours after it was written, and a view attributed to the migration it replaced

📝 Docs only · zero code lines

No behaviour changed. All three are places where the code is right and the page describing it is not, which is the worse direction: a stale page reads as an answer.

🔴 sepolia.md's generation table was off by one pool in each direction. It listed QA Flow 260806 and QA Upcoming 260812 — soft-deleted 2026-08-12 and 2026-08-19, before the audit that produced the table — and omitted FJL IDN Private Credit 1, the oldest live pool. Nothing off-chain records which implementation a pool was cloned from, so this table is not a summary of that fact, it is that fact, and for the omitted pool the record simply did not exist. The counts that hung off it moved with it: "none of the nine" is seven dev pools, and pool_implementation is NULL on all eight live ones.

⚠️ FJL is entered as having NO generation, which is different from an unread one. It is a display-only pool: deploy_status = NOT_DEPLOYED, no deploy tx, no factory, holdings seeded as ledger events. There is no clone and therefore no implementation to read, so the row says so instead of carrying a cast code command that would send the next reader after bytecode at an address this stack never deployed. 🔴 It does carry pool_address and lp_token_address all the same, which contradicts 0179:4's "no pool_address" — flagged in place rather than quietly reconciled, because both JY and CC reasoned from the doc's version of that fact before checking the row.

🔴 The same file recorded "the column is deferred" as a standing decision. It was reversed the same day. Migration 0205_pool_implementation and commit c919f5f are both 2026-08-21, the date of the audit and of the decision. The paragraph was written hours before the reversal and never revisited, so it read as current. Split in two now: "do not teach the screens to tell the generations apart" still stands, "the column is deferred" lasted a day. The column does not retire the table either — every pool that exists predates it and reads NULL.

🔴 11-db-schema presented dashboard_alert_counts as 0113's, which stopped being true at 0175. That migration re-issued the whole view, and five of the eight counts now read the ledger views instead of the tables the ledger replaced. A reader checking "what does this alert count" against the page got the pre-ledger answer. The 24h stalled threshold is written in four places — STALLED_YIELD_HOURS, admin-web's STALLED_PROCESSING_HOURS, the schema.sql snapshot, and the live migration — and the live SQL copy is 0175's; an edit made against 0113's or 0114's text would land in a superseded migration and never reach the database.

⚠️ countStalledYieldByPool is NOT a fifth copy — it imports STALLED_YIELD_HOURS. It is a second reader, and the comment in admin-web that says "all three must agree" is counting readers where it means copies. Recorded because a first pass of this correction made the same mistake.

And the threshold guard is not blind to the live SQL, which a first pass also claimed. stalled-threshold.test.ts reads schema.sql, and that file is post-0175, so live drift still fails. The real gap is that nothing checks schema.sql is regenerated in the first place — the same gap that once left seven columns living only in migrations.

⚠️ Deliberately untouched: the impairment permission wording. DL-1(b) is built on "anyone can trigger an impairment" while the contract has onlyRole(DEFAULT_ADMIN_ROLE). That is not a stale sentence — it is a judgement design that disagrees with the code, and correcting the prose would retire the open question along with it. Left for a decision.

No decision card: these are corrections to the record, not new positions. Source: JY, 2026-08-27.

📅 August 26, 2026 — Pool creation had been answering 400 on every attempt, because the wizard never sent a field the backend made required

🔨 Built · apps/admin-web · front-end only

🔴 POST /pools made accrual_rate_bps required on 2026-08-21 (v3-143) and the wizard has never sent it. The string appeared nowhere in apps/admin-web. The check runs in the handler's first lines, 334 lines ahead of the showcase guard, so showcase pools were refused too.

🔴 Nothing caught it at build time because the field is on no shared row type. The payload compiled clean and failed at runtime. CreatePoolPayload now REQUIRES the key rather than marking it optional the way reserve_bps beside it is — and that change is what surfaced the last missing call site, which is the argument for it.

Whether the backend was actually deployed was settled read-only, not guessed. accrual_rate_bps entered LIST_SELECT in the same 08-21 round; GET /pools is withOptionalAuth; the dev response carries it, and every column of the current LIST_SELECT with it. Route groups split on the first path segment, so pools.get.list and pools.post.create ship in one stack — and the required-field commit is an ancestor of the select change, so a build carrying the second necessarily carries the first.

⚠️ The safe-either-way reading was wrong and worth recording. DB_FIELDS is a whitelist: on an older Lambda the extra key is dropped and the column takes its DEFAULT 0, so the operator would enter a rate, see success, and have a pool that accrues nothing. "It would be ignored" is not the same as "nothing changes".

No default in the form, not even 0. The backend requires the field rather than falling back for the reason it states: 0 does not revert. A pool created at 0 deploys fine, accrues nothing, and looks exactly like the defect the accrual driver replaced. Empty blocks the wizard step, so the operator is asked while the form is still in front of them.

And it now appears on the review step, which it did not — the only field that can never be corrected was the only one missing from the page read immediately before submitting. 8.5% typed as 85% is 8500 bps, inside the form's own range, and deploys. Empty renders rather than the || '0' its neighbour uses: printing 0% for "nothing entered" is this field's exact failure mode.

Docs: 14-decisions v3-143. No migration (0207 already shipped). Source: JY, 2026-08-26.

📅 August 26, 2026 — A late distribution said "0 days", the redeem modal had the escrow backwards, and a matured pool's remaining coupons were dropped by the client

🔨 Built · apps/web · front-end only

🔴 Next distribution · 0 days, beside a badge reading "Yield distribution overdue". The pool page clamped the countdown to zero, so a payment three months behind rendered as arriving today — and stayed at zero however long the delay ran. The clamp was a held bug with a TODO on it, waiting for approved wording. The shared label had the other half: its late branch answered the single word overdue, which made 92 days behind look identical to yesterday and merely repeated what the left of the row already said. Status on the left, elapsed days on the right; the overview tab picked up 12 days late with no edit of its own.

🔴 "The LP you redeem moves into escrow… so it earns no further yield from that point" is the opposite of what the pool does. Escrowed LP keeps earning for its holder (v3-143); what the escrow changed is WHEN the interest is booked. PlatformPoolStorage.sol:102 says so in as many words, and previewEscrowAccrual exists so "an investor has to be able to see that queuing did not stop their interest". This is the last screen before a signature, and getting it backwards is an argument against waiting that does not exist. Both comments that justified the old wording are corrected in the same commit and now cite those coordinates — one of them stated the false half as the reason the sentence was written.

🔴 The client was clearing next_yield_due for CLOSED and MATURED — the last copy of a rule the backend dropped, and dropped for a reason it wrote down: clearing the date "removed the period from the only places it could be seen", and "a 12-month pool's final coupon is exactly the one that fell in this hole". Only DRAFT is cleared server-side now.

⚠️ Deleting that list outright would have been worse than leaving it. The client has an estimate (start_date + frequency) behind the value, and on those lifecycles a null from the server is an ANSWER rather than an absence — a matured pool that owes nothing further resolves to null and is cleared by the same write. So the list MOVED: the server's value always passes, and the estimate is refused on the three lifecycles whose silence means something. Named, with that reason on it, because as an inline array it reads as "matured pools have no date" and gets turned back into a filter.

The portfolio's Next payout row had a second gatestate !== 'FREE', under a comment saying there is no future yield once a position has matured. After maturity a repayment holder is precisely the one who lands in FREE, so the row was hidden from the only holders it was about. Shipped in one commit with the mapper, which alone would have changed nothing on screen.

Docs: 14-decisions v3-143 · v3-147. No migration. Source: JY, 2026-08-25.

📅 August 26, 2026 — Maturity is when a repayment pool starts paying, and twelve places in the investor app called it the end (v3-148)

🔨 Built · apps/web · front-end only

The investor app had one verdict for a different question. LOCKED / EARLY / FREE answers "is this holder allowed out", and eight surfaces read FREE as "right now, in full" — true of one pool shape out of four. So this is not a copy pass: the judgement now returns HOW a holding leaves alongside whether it may, derived from the axes the product already has and stored nowhere. Fixing the twelve sentences without it would have left the next pool shape to break them again.

🔴 The cycle banner read the cadence and never the maturity, so on a pool that repays after maturity it told a signed-out visitor "Requests are open now. This cycle closes Sep 17." about a pool whose requestRedemption reverts on every call. The banner is not hidden — it is the only place a visitor learns how the pool repays — so the false half goes and the explanation stays, from one string the holder panel renders too. That panel gains a state with no call to action: the button is not merely disabled, there is nothing for it to do.

🔴 "No lock-up, redeem anytime" in three places. A lock-up of zero says only that one gate is absent. The risk disclosure also rendered ONLY when a lock-up existed, so a pool that refuses every request until maturity carried no redemption disclosure at all.

"Term complete", "Final yield" greyed out, "Matured · N days left", "redeems the whole position at once" — each kept for the pool shape it is true of and replaced for the one it is not. The hero stat changes what it MEASURES: "days left" is not a question a repaying pool has an answer to. Repayment in progress sits beside the MATURED badge, which alone reads as finished.

⚠️ Two things deliberately not done. The cycle count is not on the investor API, so the two places that want "3 of 4 cycles left" say "In progress" and no number is invented. And pool-header-card counted down to end_date, the wizard's estimate rather than the deployed maturity — same defect fixed in the invest sidebar a day earlier, same maturity_date ?? end_date fallback and the same TODO(BE) on it.

🔴 Review found four defects in the change itself, all the same shape: a lifecycle flag reused as a route judgement. requiresFullRedemption covers WIND_DOWN as well as MATURED, where the cycle plan is set aside — so "the remainder rolls into the next cycle" promised a cycle that may never come. And positionState still derived the maturity gate a second time, narrower than the first: it needed a maturity DATE, so a FIXED_MATURITY pool whose date was never set read as "settles by cycle". Its own comment had recorded that case as unreachable.

⚠️ One unguarded .toISOString() on a value the mapper never validates. redemption.maturityDate is new Date(row.maturity_date) with no check, so a bad column arrives as an Invalid Date and .toISOString() throws — in a render path, blanking a whole page over one label. Guarded at the readers; the mapper is a separate round, because making it null there would move a pool from "matured" to "no maturity" on every screen at once.

Docs: 14-decisions v3-148. No migration. Source: JY, 2026-08-25.

📅 August 26, 2026 — The cycle a transfer credits is the one being settled, and the admin screens had been naming the other one (v3-91)

🔨 Built · apps/admin-web · front-end only

A cycle pool carries two cursors. acceptingEpochId turns at the request cutoff; currentEpochId turns when the settlement runs. They part at every cutoff and rejoin at every settlement, so they disagree for exactly recall_lead_days per cycle — and that span is the only window in which the fund manager can act.

🔴 Every screen mixed them inside one row. The amount came off the settling cycle — the contract credits epochFundTopUp to currentEpochId (v3-91) — while the cycle number, the deadline and the cutoff came off the accepting one. On the fund card that read Cycle 2 · due Sep 27 over money the pool owes as cycle 1 on Aug 30: one cycle too high and 28 days too late, on a pool where arriving late means the cycle settles short and the shortfall does not roll forward.

🔴 The root cause was one line. AWAITING_FUNDING was now >= requestCutoffAt, and that cutoff belongs to the ACCEPTING cycle, which acceptingEpochAt keeps in the future by construction. The phase therefore practically never fired, and three derivations already written correctly against it sat dead: the settling node's amount, the aside's cursor switch, the overdue badge. accepting !== settling is an identity of acceptingEpochAt, and both values were already on the summary — no new chain read.

settlingCycleLabel, normalized once at the API boundary. Raw currentEpochId is 0 until the first redemption request moves it, while epochFundingDate(0) already answers with cycle 1's date — an off-by-one that reached a signing dialog as "Fund Cycle 0". Two fields rather than one, because the raw 0 is still the fact write guards test: Postpone settlement was gated on the window having opened, which is a clock fact, so on a pool that had never settled it offered a control the backend answers 409 to.

Date priority, four sites. pools.next_funding_date mirrors the accepting cycle alone, so nextFundingDate ?? settling let it win on a row naming the settling cycle. It reads correctly on dev only because every mirror is still NULL; one Confirm turns it wrong, so it is pinned by a test rather than by a look at the screen.

The Fund dialog says whether the figure is finished. Requests keep arriving until the cutoff and a cycle spends most of its life inside that window, so "what is on screen is what is owed" is false more often than true — and what a settlement does not spend stays with the cycle it was sent for. ⚠️ The closed branch carries no date: the settling cycle's cutoff is not in the summary, and deriving it from the funding date here would be a fourth copy of arithmetic D3 keeps in one place.

⚠️ The aside's Demand is relabelled Open requests · all cycles. redemption_epoch_summary has no epoch filter on demand_lp, so it is the pool's whole book under a heading that now names one cycle. The number is untouched; the RPC filter is backend.

Docs: 14-decisions v3-91. No migration. Source: JY, 2026-08-24.

📅 August 25, 2026 — After maturity the coupon rides the repayment cycle, and the contract's promise layer gets its first writer (v3-147)

🔨 Built, deploy pending — the sweep gains an IAM grant, so nothing publishes on chain until cdk deploy

The agreement. 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 and no third that does both, but one date.

🔴 Two ladders cannot stay on one date when only one 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: last paid period's end plus one interval. Pay once on the 20th instead of the 10th and every later coupon moves with it while the cycles stay put. The agreement broke on the first late payment and stayed broken.

Now: before maturity nothing changes, there being no cycle ladder to attach to. After maturity, yield period n is due on cycle n's payout date — stored, not derived, so a late payment shows as late instead of moving the next one.

No migration. redemption_epochs.funding_date already holds the date each cycle was given, and its comment forbids anything else being written there. After maturity the coupon date is that date. ⚠️ Its NULL is load-bearing: it means the chain was never given the date and is deriving it, three days early in the first month and eight by the fourth, so unwritten cycles are left out rather than read.

🔴 Order: the maturity cap first, the plan second. A period that came due while the term was running is late, and handing it to the plan would move it onto a cycle date and quietly un-late it. The plan takes over at the point the cap used to end the schedule.

🔴 setYieldDueDate had zero callers. v3-143 ③ gave the contract a stored promise layer beside its linear forecast, and nothing off-chain ever wrote to it — so the pool published a date the product had not promised, and settleYield kept advancing that layer by 28 days on its own. The daily yield-due sweep now writes it, which makes the sweep a signing function: ORACLE_ROLE plus its own execution role, since a signing function on the shared role hands kms:Sign to every scheduler beside it. Which period to write is read from the chain (yieldPeriodsSettled + 1), never derived — the index counts yield periods from the anchor, not cycles, and the offset moves the first time a coupon is missed.

Non-fatal by construction. yieldDueDate gates nothing on chain, so a failed sync leaves a stale announcement and touches no money. 🔴 A failed read, though, is never passed on as "no dates": that resolves to "the schedule is finished" and gets written, which would clear next_yield_due on every repayment pool at once.

The two amounts, and the two screens that show them. GET /pools/{id}/repayment-cycles answers per cycle with a principal axis and a yield axis — each with its own status, the one figure to show, and whether it is a projection. No combined total: it is false while one side is outstanding, which every cycle passes through. The admin repayment card gains a two-row column; the FM's cycle block puts both funding actions side by side, in no order.

🔴 redemption_epochs.total_demand_lp had never been written (one definition, zero writes), so principal figures come from the chain. Dates still do not — a derived date is indistinguishable from a given one there — and the amount reads happen only for cycles that HAVE a given date. 🔴 epochFundTopUp is reported as funded_usd before settlement and surplus_usd after, because a settlement debits it first and reading the leftover as a contribution calls a repaid cycle unfunded.

🔴 A hole in the same rule, 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 and that cycle's coupon came due never. One rule now, shared. Found with it: the grid's fallback start was the day the offering OPENED, so a pool's first coupon covered its own fundraising window.

The investor view, with the holder's OWN amounts (JY, 2026-08-26) — and the two rows are not the same kind of statement. 🔴 Yield can be a per-holder fact (yield_distribution_investors records what the contract credited each holder, from claimableYield either side of the settlement). Principal has no per-cycle figure for anybody: one claimRedemption settles every cycle since the holder last claimed, so RedemptionClaimed carrying no epoch id is not an omission — the settlement unit is the claim, not the cycle, and splitting it off-chain would mean re-deriving the two-tier ladder about money.

So the principal row states a state, which is the more useful thing: post-maturity repayment runs through requestRedemption, so a holder who never made one is repaid nothing indefinitely and nothing tells them. That row carries the redemption action — on exactly one cycle, the earliest unsettled one with a date.

Docs: 14-decisions v3-147. No migration. Source: JY, 2026-08-24.

📅 August 25, 2026 — A lock-up with nothing to count from, and a date the form still let you move out from under it (v3-146)

🔨 Contract deployed, off-chain built — not live until FACTORY_ADDRESS_84532 swaps

The contract half of v3-144 shipped: subscriptionEndDate lives in RedemptionConfig, isLockupActive counts from it and dropped its investor argument, and PoolConfigLib.validateTerms judges the three terms as a set — at initialize and in every setter. 🔴 It refuses rather than clamps: an earlier revision capped the unlock at maturity, which let a pool deploy and then enforce less lock-up than it advertised.

🔴 The date the form still let you move. subscription_end_date was editable on a live pool, on the reasoning that it "reaches no contract" — true until this round. On a pool-wide pool the chain holds its own copy, nothing writes an edit back to it, and the contract refuses one once the pool holds a deposit. So an edit moved our half alone, permanently, in the direction that reverts: clear the date and lockupAnchorMs answers null, the backend reads "no lock-up" and accepts a redemption the contract refuses. Now locked in the edit form and in PATCH /pools/{id} from one shared rule — and only where the chain reads it, so a pool with no lock-up, and every per-investor pool, keeps the field.

🔴 An open-ended pool carries no lock-up (JY). Its offering window is optional, so a pool-wide lock-up there has nothing to count from. Rather than demand a close from a pool whose whole shape is "no scheduled end", the combination goes. That leaves an open-ended pool with no exit gate at all — a penalty needs a maturity to be early of — which is how every open-ended pool on dev already runs. A minimum holding period waits for the per-position round.

Both refusals are written as properties of the ANCHOR, so they lift by themselves if maturity ever becomes per-position, along with v3-144's reopen ban. lockupWithoutAnchorReason retires: it sat in a branch where it could never fire, and the state it guarded is now unbuildable.

⚠️ Found on the way: maturity_model was not in PATCH's field whitelist, and unknown keys are dropped, so the edit wizard's maturity-model select saved nothing on a DRAFT with no error. It is a real Class B field now, and its enum plus its pairing with maturity_days moved into validatePoolConfig so create and patch judge them the same.

Docs: 14-decisions v3-146. No migration. Source: JY, 2026-08-25; contract by CH (Base Sepolia round 2026-08-25, apps/contract/sepolia.md).

📅 August 24, 2026 — The term used to start when somebody pressed Deploy (v3-145)

🔨 Built, deploy pending — off-chain only, no contract change

pools.worker.deploy computed maturityDate = nowSeconds + maturityDays. So a draft's maturity slipped by however long it sat undeployed while every other date stayed put, and — the half that reached investors — the term started before the pool had finished raising. Somebody 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 that as a known defect whose decided fix needed a contract change and was never built.

It is now subscription_end_date + maturity_days. maturityDate is an absolute timestamp the backend computes and sends through setMaturityDate, so this is one expression, no ABI, nothing for the contract team. 🔴 And it fixes the late-depositor defect without making maturity per-position — maturity did not become per-holder, it stopped starting early.

Three guards retire, and one is new. The W9 ceiling (offering close ≤ maturity − one yield period), maxOfferingDays/minEffectiveDays and the "late investors receive only N payments" warning all existed because the offering was carved out of the front of the term; nothing is carved out now. The late-investor lock-up guard (D-7) goes with them: both ends of the term share an anchor, so its condition collapses to one the wizard has always checked. ⚠️ v3-144 had recorded that D-7 could never retire — right about the lock-up moving alone, wrong once maturity moved with it.

🔴 The new one: a FIXED_TERM pool needs an offering close. A blank close used to mean "raise until the term ends", which is circular once the term is defined by the close. Refused at the publish gate, at POST /pools/{id}/lifecycle, and in the deploy worker — the worker is not redundant, because a retry skips the publish validations and setMaturityDate is skipped rather than failing when there is no value, leaving a pool the chain reads as never maturing.

⚠️ Only new deploys. A pool already on-chain keeps the maturity it was given, and its end_date still holds the old start-anchored estimate, which the Configuration tab reads — DeployDateReview now names that staleness instead of the drift it used to report.

📅 August 21, 2026 — The lock-up moves to the offering close, and both anchors stay live because pools are clones (v3-144)

🔨 Off-chain built, contract handed to CH

Maturity is pool-wide and the lock-up was per holder, 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. The lock-up now counts from the pool's offering close, which nobody is invested before.

🔴 This is not a migration and there is no cutover date. A pool is a Clones proxy pinned to its implementation at creation, so every pool that exists keeps the per-investor rule for ever and the investor app has to answer for both. lockupAnchorMs (@aset/types) is the only place that decides, shared with POST /redemption-requests so the button and the gate cannot disagree.

The unknown case falls to pool-wide, because only one direction is safe. close + lockup ≥ investedAt + lockup always, so guessing pool-wide shows a later unlock (conservative, nothing can fail) while guessing per-investor offers a redemption the contract refuses. So the list holds the implementations that are per-investor, and a pool with no recorded implementation is one of them (it predates 0205).

A reopen is refused while lockup_days > 0 on a pool-wide pool: keeping the old close leaves a new investor with a lock-up that has already run, and moving it re-locks holders who had cleared theirs. The refusal names the anchor, not the reopen, so it dissolves if maturity ever becomes per-investor.

🔴 Two defects found on the way. create-pool.ts still called poolImplementation(), renamed to poolImplementationFixed() in this round's redeploy — the old name reverts, the read is 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. And invest-sidebar computed its own unlock date from Date.now() instead of going through getRedemptionState, so it would not have followed any of this.

The wizard's maturity-overrun guard is not retired by the move, and an earlier revision of the notes claimed it was. The condition is lockup > maturity − offering either way; what changes is that it stops describing one late holder and starts describing all of them.

📅 August 21, 2026 — The button had been there since the 4th, and the chain had refused it every single time (v3-142)

✅ Shipped — new implementation + factory on Base Sepolia, and W9 is deployed end to end

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; not after it, where the new CLOSED branch returned to == MATURED alone. Every reopen on a deployed pool reverted and the handler answered 502.

🔴 Four surfaces were asserting a capability none of them had checked. 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 CLOSED → ACTIVE transition row, and pools.post.close called a close "a judgement call about market conditions, not a terminal fact" in its own comment. The endpoint existed, so the promise read as verified — CLAUDE.md rule 2-b, in the one place where the check is a single function in a different repo directory.

CLOSED now has two exitsMATURED when the term ends, ACTIVE when the offering reopens (3edf75b). ACTIVE ↔ CLOSED is an intended cycle; the one that had to go was CLOSED ↔ MATURED, and v3-141's round closed it by removing the dead MATURED → CLOSED edge. MATURED is still where the admin setter ends. LifecyclePolicy is internal, so this inlines: no ABI, selector or topic0 change, and nothing off-chain moved.

It cost a second deploy. The decision landed after b619d9a was written, so the 08-20 factory shipped two of the three transitions. poolImplementation is immutable and pools are Clones, so an edge cannot be added to a live implementation: new impl 0x405E89Ec…, new factory 0x206fC348…, FACTORY_ADDRESS_84532, API redeploy. Worth doing at once because no pool had been created from the 08-20 factory yet, so nothing was frozen without the edge. ⚠️ Confirm the env value, not just that a deploy happened — the API stack was also redeployed that day for W9's column, and if that went out first it still names the old factory.

🔴 A reopen keeps a FUTURE offering close, and the dialog now says which one. subscription_end_date is cleared only when it is in the past — otherwise pass 3 re-closes the pool within the hour and reverses the operator with no explanation beside it. A future date is deliberately kept: the pool was closed early by hand and still has a planned close ahead. The old confirm said none of that, so a reopen working exactly as designed read as one that had not stuck.

⚠️ This Clones cutoff bites harder than v3-141's. That one only reached pools with a subscription_end_date; any pool can be closed by hand, so this one is reachable everywhere. And nothing off-chain records which implementation a pool came from, so no screen can tell the generations apart. Audited from the chain: none of dev's nine deployed pools can reopen, seven predate the 08-18 round entirely, and two carry $72,000 / $48,800 of testnet USDC, which retires the old "every dev pool is a QA pool under $50" note. Resolution is operational (recreate dev's pools from the current factory, JY); the per-pool generation column is deferred, and becomes necessary before prod.

A pool now records which contract generation it is (0205). Nothing did, and the only way to answer was to read the 20 bytes after 363d3d373d3d3d363d73 out of each proxy's bytecode — not something a screen can do while rendering. pools.worker.deploy now writes pool_implementation from factory.poolImplementation() and pool_factory alongside it. The admin reopen control shows the reason in place of the button when the pool's implementation cannot do it, and pools.post.close answers 409 rather than letting the chain revert into a 502. 🔴 The rule is a closed historical list of implementations that CANNOT, not an allow-list of ones that can: an allow-list needs appending on every redeploy and silently withdraws the capability the first time somebody forgets. Reversal worth recording: this was written down as deferred in the morning, on the reasoning that dev's pools would be recreated from the current factory; dev's pools are being left alone, so the generations coexist and the column is the only thing that can tell them apart. It does not make an old pool reopenable — nothing can.

Start Date and Maturity can no longer be emptied on a published pool (JY). Clearing a date input sent '', PostgreSQL refused it for a DATE column, and the handler answered 500 — a write that was already impossible, failing in a way that named nothing. Both are now refused by name in the Configuration tab and with a 400 in pools.patch.update. They stay impossible rather than being made to work: clearing end_date is not "no maturity" (resolveMaturityAt falls through to start_date + maturity_days, so the term changes hands, and with maturity_days absent the pool can never be swept to MATURED), and clearing start_date removes the key the activation sweep runs on and the anchor the yield schedule counts from. subscription_end_date remains clearable, because NULL is its designed value.

W9 is now deployed end to end — 0204 recorded in schema_migrations, GET /pools returning subscription_end_date, admin-web out, both contract legs on-chain. Also this round: the offering-period field stops showing an error before anyone has typed in it, the timeline stops following the scroll, its two left-hand date labels stop printing on top of each other (a short offering is the normal case, not an edge case), and the publish and retry-deploy paths now re-read the dates and refuse a stale offering close — maturity is derived at the deploy instant while the offering close is absolute, so a draft held long enough deploys a pool the close sweep shuts before a deposit can land.

📅 August 21, 2026 — Escrowed LP earns, the pages that still said otherwise were the stale ones, and the accrual denominator had the wind-down term written into it (v3-143)

✅ Shipped — contract built 2026-08-20 and on chain 2026-08-21, migrations 0206–0208

v3-131 (3) is in code. FixedYieldEngine + YieldAccrualPolicy replace the per-share accumulator: yield accrues with time against totalLpSupply − poolHeldLp, and an escrowed holder keeps earning — the entitlement accrues per request on the epoch ladder (epochGy) and is recognised, debt and credit in one instant, when the escrow ends. previewEscrowAccrual(requestId) is the lag while it is open. ⚠️ New pools only (poolImplementationFixed is immutable and pools are Clones). Implementation 0x7C21153E… + factory 0xE8453DAc… were verified on Base Sepolia 2026-08-21, the deployed bytecode reproducing this branch's source to the byte — but poolCounter() is 0, so no accrual has been driven and no escrow priced on chain (the behaviour rests on forge test, 766/766), and every pool that exists is pinned to an older implementation.

🔴 The status cells said the opposite of the prose, and the prose was right. Five places still carried item (3) as unbuilt — 07-redemption had it as "Undecided, unbuilt" in the what-has-to-change table plus "still undecided and unbuilt" in the section intro, 14-decisions had it as 🔴 MVP, 16-timeline had it under ○ Planned as "the last MVP item", and 10-status-machines closed its "the flow below is current" summary with "yield stops at request". Undecided was the worst of them: it had been decided and built. Meanwhile 07-redemption, 06-writedown-nav, 26-glossary and this page all described the shipped rule correctly. ⚠️ A handoff written off the stale cells reached the opposite conclusion and would have removed the correct investor copy; the cells are what moved.

🔴 And the accrual denominator was printed with the wind-down term. The three-denominator table gave yield accrual as totalLpSupply − settledUnclaimedLp. The code uses totalLpSupply − poolHeldLp (FixedYieldEngine.accrualBasis); settledUnclaimedLp belongs to R10, wind-down, and the accrual basis never used it. Two separately-argued rules folded into one term — the precise failure the line above that table warns about. Also corrected in 23-money-path, in 05-investment-lifecycle — whose onLpTransfer note said the lock "moves neither side of the accrual basis", which is the settledUnclaimedLp formula written out in words — and in v3-131's own text, which had the same substitution plus two claims that followed from it.

⚠️ Three denominators, and now all three differ. Ordinary NAV excludes nothing; yield accrual excludes all pool-held LP; wind-down excludes only the settled-unclaimed slice. The old note said two of them excluded the same set — true when it was written, false since this round.

⚠️ accrualBasisLp can read zero, and that is not the old defect returning. On a post-maturity epoch pool every share is escrowed the moment the window opens, so zero is the ordinary case. Under the old rule the window's interest then belonged to nobody and piled up in unclaimedYield; now zero means nothing is recognised yet and the window is still owed to the holders who are in it. Pinned by test_Repro_Deal_FourCycleWindow_InterestIsOwedToTheHolders, with testFuzz_escrowNeverShrinksTheTotalBill holding the total the partner owes flat across escrow and test_EscrowEarnsTheSameAsStayingIn matching escrowing to staying in within 4 wei.

Off-chain: accumulatedYieldPerShare / yieldDebt retired (0206), pools.accrual_rate_bps added create-only because the contract has no setter (0207), exit-window anchor (0208). POST /pools requires the rate, GET /portfolio-positions returns escrow_accrual, the yield due row carries outstanding_net_liability.

📅 August 20, 2026 — One column was answering two questions, and it got away with it because both answers were the same number (v3-141)

🔨 Built, not deployed — 0204 IS applied to dev (by hand), and one leg needs a contract change nobody has made

pools.end_date was read by pools.scheduler.lifecycle as the offering is over (ACTIVE → CLOSED) and by resolveMaturityAt as the term is over. Both readings pointed at the same day, because the create wizard computed end_date = start_date + maturity_days. The consequence was not a bug anyone could see: a pool took deposits until the day it matured, so money arriving on the last afternoon was invested for one day and then joined the queue for principal repayment. No screen was wrong and no field held an indefensible value.

🔴 The obvious repair moves maturity. resolveMaturityAt reads end_date ahead of start_date + maturity_days, so shortening it on a published-but-undeployed pool shortens the term — and resolveNextYieldDue caps the schedule at maturity, so obligations disappear along with the date rather than failing anywhere. Hence a new column, subscription_end_date (0204), with end_date left meaning exactly what it meant.

The auto-close pass had never run once. It re-reads ACTIVE pools after the maturity pass, and both compared the same date, so a fixed-term pool always left as MATURED and was gone before the close pass looked; CLOSED was reachable only by hand. Keying it on the new column is therefore switching a path on, not adjusting one — and that exposed the assumption underneath it: 🔴 the maturity pass selected ACTIVE only, so a pool that closed early could never mature. Penalty-free redemption, the notifications and every badge key on MATURED, not on the calendar, so such a pool would have sat in CLOSED past its term with redemptions never opening. The filter is now ACTIVE, CLOSED — narrowed to a CLOSED pool that carries a subscription_end_date, i.e. one that got there through the close pass. 🔴 A hand-closed pool is deliberately left out: closing by hand is reversible (reopen returns it to ACTIVE) and MATURED → ACTIVE is in no transition table, so sweeping those would quietly remove an option operators have today, immediately and with nothing on-chain in the way on any pool without an address. The pass order is unchanged, because the offering close comes first in time.

That second half needed the chain, and got it in b619d9a. Until then LifecyclePolicy.isValidTransition treated CLOSED as terminal and transitionPools writes on-chain first, so the leg was correct and inert. The fix deletes MATURED → CLOSED at the same time: the chain allowed that edge and no off-chain path ever used it, so keeping it would have turned CLOSED ↔ MATURED into a cycle. ⚠️ Pools are Clones — only pools created after the new implementation and factory go live get the new DAG, and PlatformPoolFactory.poolImplementation is immutable so the factory is replaced too. Earlier pools keep CLOSED terminal and cannot reach it (no subscription_end_date).

The ceiling is one yield payment and it lives in the wizard, not in the endpoint: subscription_end_date <= maturity − one yield period (30 monthly / 90 quarterly) blocks the step, and a softer band three periods wide warns and names how many payments the last investor in would receive. 🔴 Not the epoch cadence — yield runs on a calendar axis and post-maturity repayment on a 28/84-day one, and copy may not say "cycle" for that reason (a test asserts it). 🔴 Only the FIXED_MATURITY + epoch pool is asked: everywhere else a holder can leave on request or there is no maturity to measure against, so a ceiling would block pool creation that works today. Every other shape leaves the column NULL, which the sweep skips.

Step 5 of the create wizard became Schedule & redemption. The start date moved out of step 6, where it sat beside the publish button — the date that decides when the pool opens and when the yield cadence starts counting was being answered after everything that depends on it. A timeline bar draws offering / yield-only / repayment to scale with the shortest effective term bracketed underneath, because the defect was invisible as numbers and is unmissable as a bar with no middle. Lock-up is deliberately not drawn: it starts on a different day for every holder. 🔴 Steps 4 and 5 must not be swapped — the ceiling is computed from step 4's cadence.

🔴 Both pool read selects had to learn the column too, and that half is the one that hides: LIST_SELECT / DETAIL_SELECT are explicit allow-lists, so an omitted column arrives as null and every screen renders a confident wrong answer rather than breaking. The pool-edit form seeds this field from that value and sends it back, so a blank seed would have written null over the stored date on the next save. ⚠️ Apply 0204 before deploying these Lambdas: Supabase rejects a select naming a column that does not exist.

"End date" is gone as a label in the pool-edit form, the Configuration tab and the pool overview, replaced by 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 ceiling is a stopgap with a known end. It exists only because maturity is one pool-wide date; once maturity is per-position (D2) a late entry costs its holder nothing and there is nothing left to protect. features/pool-form/offering.ts says so at the top and asks to be deleted rather than ported.

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.

Docs: 14-decisions v3-141 · 10-status-machines → pool lifecycle · 11-db-schema → pools. Migration 0204, applied to dev 2026-08-20 by hand under its original number 0194 and renumbered when ch/product turned out to hold 0194–0203; schema.sql updated in the same change. Source: JY (work order W9), 2026-08-20.

📅 August 18, 2026 — A setting that turned off the only watcher and appointed nobody in its place (v3-140)

🔨 Built, not deployed — migration 0192 is comment-only and not applied

pools.epoch_cycle_mode offered SEMI_AUTO ("remind an operator to confirm each cycle's payout date") and AUTO ("do not"). v3-135 had already redefined that reminder as the result of a state rather than a preference — the only question is whether this cycle's date has been set — which left the column answering nothing. Removing it is the tidy half.

🔴 The untidy half is what AUTO actually did. 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, and the chain answers anyway: PoolCommonLib.epochFundingDateAt returns previous + epochDurationDays for a cycle it was never given, looking back exactly one step, and settlement freezes that value into storage (RedemptionLib.sol:894). No revert, no failure — the wrong date crawls forward one cycle at a time. It was a switch labelled "fewer emails" that changed which dates a pool pays on.

And the on-request combination is the worse half, not the exempt one. The seam v3-135 recorded was framed 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 precisely the model where nothing is written ahead and the operator confirms each cycle as it comes — so there AUTO makes nobody has decided this date the pool's default state rather than an exception within it.

Neither remedy v3-135 proposed is what shipped, and neither would have sufficed. Stopping the wizard from sending AUTO leaves the 0104 NOT NULL default sitting in the column; moving the plan branch above the mode check leaves every on-request pool still gated on it. The check comes out of the gating outright. The sweep now runs two branches and no mode test, asking one question from two kinds of evidence: a plan pool is answered by the set difference against redemption_epochs.funding_date and needs no clock, while an on-request pool creates its row only when the cycle arrives — so "cycle N+1 has no date" and "cycle N+1 does not exist yet" are indistinguishable there and the lead window before windowOpen is what remains.

Create sends the column at all now, letting the 0104 default fill NOT NULL: writing a literal would have code re-assert a choice nobody made, and every existing row is SEMI_AUTO anyway. pool-edit was also round-tripping the field through its editable-permissions list and PATCH payload with no UI attached, and went with the rest. The only runtime reader was ever the one sweep; the other two schedulers carried comments saying they do not gate on it.

The column stays and only the reads go. Dropping a column to express "we stopped caring" rewrites history for the pools created under the old reading, so 0192 is a comment and nothing else — no DDL, no row touched — and the comment says the honest state: present, populated, unread. 🔴 It also says not to reintroduce a read: "stop reminding me about this pool" is suppression of a notification and belongs with the notification preferences, not in a schedule column. Nothing inherits a behaviour change either — measured on dev, all 8 pools are SEMI_AUTO and none is a plan pool, so no pool has ever been on AUTO.

⚠️ The schema.sql / 11-db-schema.md pair landed in two different commits, by two different sessions. The CLAUDE.md checklist asks for both together and both are done — the snapshot's DEPRECATED (0192) comment shipped with the code, this page's column row came with the docs round — but a reader diffing a single commit sees one half and concludes the other was skipped. Recorded rather than repaired: splitting the work was deliberate, and rewriting history to hide the seam would cost more than the note.

Docs: 14-decisions v3-140 · 22-notifications → epoch_funding_date_due · 11-db-schema → pools. Migration 0192 (comment-only, pending dev apply). Source: JY (product), 2026-08-18.

📅 August 17, 2026 — A cycle nobody wrote is not blank on-chain, it is a wrong date that hardens every settlement (v3-133 – v3-139)

🔨 Built, not deployed — migrations 0189 / 0190 are still not applied to dev, and none of this touched a contract

v3-132 shipped a wizard that can express a post-maturity repayment plan and left the dates to a post-deploy card. Building that card turned up the reason the dates could not wait: 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 answers with a date rather than a blank. On a calendar plan on the 15th the wizard's Mar 15 · Apr 15 · May 15 · Jun 15 becomes the chain's 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.

So the deploy writes all N (v3-133), at the only instant it can — maturity is a real timestamp there, which makes it the first moment the plan can be rebuilt against it and the last before the pool is live with an incomplete schedule. Cycle 1 is already on-chain as the anchor and the rest are walked forward from it, so the whole list belongs to one schedule. 🔴 The cap is the clock, not a transaction count: a count would be guessed from a per-transaction wall time nobody has measured, and the guess fails on the day the chain is slow, which is the day it matters. 🔴 A truncated run is not a failed deploy either — the pool is on-chain and correctly configured, DEPLOY_FAILED on a live pool is a false record, and each missing cycle can be written later until its own window opens. What it must not be is silent.

Two writers, opposite orders, and merging them breaks one silently (v3-134). The deploy writes ascending so that a truncation leaves the far cycles unwritten rather than the near ones. The confirmation card writes descending, because setEpochFundingDate is refused once a cycle's window has opened, that window derives from epochFundingDateAt, and an unwritten predecessor makes it return 0 — a zero window reads as "not open yet", so writing cycle K is what arms the guard on cycle K+1. Ascending there can lock a cycle out on the strength of a date nobody chose. The module takes the order from its caller rather than choosing, and the test suites are split on purpose.

The reminder became a consequence rather than a preference (v3-135). v3-132 forced epoch_cycle_mode = AUTO on this shape because "every date is fixed up front" — a premise that stops being true the moment the write run is bounded by a clock. The gate is now redemption_epochs.funding_date IS NULL, through the same function the epoch summary uses so the two cannot disagree, and the date the notice proposes comes from the stored rule walked from the anchor instead of previous + cadence, which on a roll-15 plan named the 12th. Until the confirmation card is deployed this notification is the only recovery path: the deploy does not retry, because deploy_status is already DEPLOYED when the writes run and a redelivery is turned away by the idempotency guard.

The rule-column ban was drawn one step too wide, and is redrawn (v3-136). v3-132 said nothing may read epoch_date_basis / epoch_roll_day at runtime; the wizard preview, the confirmation diff and the reminder all must. The line is not reading, it is who acts: a date a stored rule produces may be shown to a person, never written to the chain and never used to decide a schedule.

🔴 The same bug was caught in three places in one round, which is the round's real character. All three re-derived a date from a rule and produced a confidently wrong one with no failure signal: the wizard's copy claiming every date was set up front, the deploy's own writer, and the reminder proposing "confirm 05-30" on a roll-15 plan. The mechanism is one distinction — generateFundingDates walks from a first date, repaymentPlanDates finds the first date itself and ignores the one it was handed under CALENDAR, landing on the end of the month with rollDay: 0. Both anchored callers now go through planDatesFromAnchor and the plan primitive carries a warning.

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

Data, and the line between plan and fact. 0189 holds the plan on pools; 0190 adds redemption_epochs.funding_date, written from EpochFundingDateSet and nothing else, because NULL has to keep meaning nobody set this. It counts from cycle 2: cycle 1 comes from setEpochSchedule, which emits EpochScheduleSet, so counting from 1 would report every plan one cycle short for ever. GET /pools/{id}/repayment-cycles exists to expose that null and deliberately does not read the chain, since a chain read answers for every cycle and erases the distinction; a ledger read failure is a 500 rather than [], because an empty list reads as "no cycle has a date" and the card would offer to write all of them, one transaction each.

POST /pools/{id}/epoch-schedule takes an optional epoch_id (v3-139) so the card can finish what the deploy could not, with the accepting cycle still the default and a 409 for anything behind it. 🔴 The pool row is not mirrored for a future cyclenext_funding_date and its provenance columns describe one cycle, and filling them from a cycle-4 write would have the pool announce cycle 4 as next and mark it confirmed before its window opened. That made mirrored: false ambiguous between "failed" and "nothing to mirror", so mirror_scope separates them; without it a caller retries and sends a second transaction to a chain that already agreed. And the ordering nobody was checking is checked, against both neighbours and including derived ones, skipping a neighbour that reads 0 rather than treating it as 1970 — the pattern was already there one function below in setEpochSettleAfter, which does compare against the funding date. ⚠️ The guard is in the endpoint, not in GovernanceLib: the ORACLE key calling setEpochFundingDate directly still bypasses it. That is why it needed no deploy round and covers pools already live, and it is a guard against operator error rather than against a compromised key.

🔴 A P0 that predates all of this: since the KMS key split, every new pool was born unable to sign (v3-138). Admin and oracle used to be one key, so granting a role to the deployer granted it to the signer by accident; the split made them distinct and create-pool.ts kept naming the admin signer. Nobody had created a pool since, so it shipped and waited. Such a pool reverts on all nine oracle paths and all five PAUSER-gated functions. ⚠️ Close, wind-down and impairment were never brokensetLifecycleStatus is DEFAULT_ADMIN_ROLE; what failed was the clearOnChainPause leg beside them. The earlier version of this record said otherwise, and a correct conclusion resting on a wrong premise misleads whoever reads it next. Grant before revoke, pinned by comparing call indices.

🔴 And one live copy defect, whose origin is a decision card. v3-100 closed v3-93's over-funding item with "leftover rolls forward to the next epoch". It does not: epochFundTopUp is keyed by cycle, a settlement reads only epochFundTopUp[currentEpochId] (RedemptionLib.sol:955), and reserveBalance += appears exactly once in the repository (PoolLedgerLib.sol:73), so nothing moves a top-up anywhere (v3-137). It is not lost — it stays in totalEpochTopUp and reaches holders through the wind-down numerator — but it is bound to its cycle. over_funding_detected still emails fund managers "carries over to the next epoch, so no action is needed", and the expensive half is the second: the next cycle has to be funded in full again, so the instruction is exactly inverted. catalog-integrity.test.ts asserts /carries over/i, pinning it the way v3-96's fabricated "automatically" was pinned. The card and this page are corrected; the copy fix is still to make in apps/infra, and saying so is the point — a true page over a false mail hides the defect rather than fixing it.

Also corrected while writing this up. schema.sql was seven columns behind (0024 · 0071 · 0088 · 0111 · 0161), found by replaying 191 migrations and diffing; residual zero. Two causes, neither carelessness: the checklist required 11-db-schema.md and not the snapshot, so a commit could follow the rule perfectly and still leave it stale; and where the drift was noticed it was written down instead of fixed ("left alone, not this change's scope"). CLAUDE.md now puts schema.sql first, and the file's header says why: it is a tool, not a document — treat it as a document and scope boundaries appear, treat it as a tool and they cannot, because a wrong tool is simply broken.

Docs: 14-decisions v3-133 – v3-139 · 07-redemption → how the dates reach the chain · 11-db-schema · 15-api-reference → Pools · 22-notifications · 09-rbac · 24-field-governance §5. Migrations 0189 · 0190, both pending dev apply. Source: JY (product), 2026-08-17.

📅 August 16, 2026 — The post-maturity repayment wizard, and a deploy that now refuses an anchor maturity has outgrown (v3-132)

🔨 Built, not deployed — the API is a manual deploy, so a green push does not mean this is live

v3-131 opened the combination on paper. This is the wizard that can express it, plus the one number the design turns on and had nowhere to live.

The wizard asks in a different order, because the old order asked the machine question first. The on-request block opens with the cycle length; here that is the third question asked and the first one answered, since how many payouts there are and what the partner wires against both decide it. So the post-maturity block runs term → date basis → window and lead → first payout → preview, and each step only asks what the ones above left open. Picking the calendar basis removes the cycle-length question entirely: the calendar sets the dates, and epoch_duration_days follows from monthly or quarterly. It still ships, because the chain requires it, and the span error that references it now quotes the number rather than pointing at a field that is not on screen.

Three columns (0189), one of which is the point. redemption_term_epochs is the plan's length as a cycle count; epoch_date_basis and epoch_roll_day are the create-time answers, kept so the post-deploy card can rebuild the same list rather than ask twice. 🔴 Nothing reads the latter two at runtime and nothing may start to: the dates are the artifact, and a scheduler deriving dates from a stored rule is the silent fallback this design exists to remove. Roll days stop at 28, because February does.

The deploy refuses an anchor that maturity has outgrown. Maturity is counted from the deploy instant while the funding date is absolute, so a draft held for a week matures a week later against an anchor that did not move. On this pool shape that is fatal quietly: requests before maturity revert, so cycle 1 would spend its whole window refusing everyone, and neither setter can repair it afterwards. The worker now fails the deploy with the corrected date in deploy_error. Scoped to FIXED_MATURITY, since an on-request pool takes requests before maturity as a matter of course.

Reminders off for this shape. epoch_cycle_mode = AUTO, because the SEMI_AUTO reminder derives the date it names from "previous + cadence" and would announce a date that is not the one on-chain. The live DB has no deployed pool on AUTO (checked 2026-08-15), so nothing inherits a change. (🔴 Reversed the next day by v3-135 — see the August 17 entry above.)

Not in this round, and not implied by it: the post-deploy confirmation card and the endpoint that accepts a cycle number. The wizard's date list is a preview and says so on every row. (Both landed on August 17, and the writes moved into the deploy — v3-133.)

Reviewed on screen, which moved three things. An error and a neutral hint no longer sit under the same field: the hint said "the term divides into whole payouts" directly under an error saying it does not, and a hint that cannot describe what the current value produces has nothing to say. The proportion the old cycle bar carried came back as a line where the two fields that decide it are entered ("Requests open 7 of every 28 days"), because the date table gives exact windows but not the shape of a cycle. And W4, the maturity-to-cycle-N timeline, is dropped rather than left open: the table already lists what it would draw, and drawing a pre-deploy estimate as a timeline lends it the authority of a schedule.

The investor app stopped reading the cadence before the maturity gate. Three surfaces asked "does this pool have a cycle?" first, so a pool that refuses every request until maturity was labelled Epoch · 28d on the browse card and filed under "settles by cycle" in the portfolio. The liquidity model gained AWAITING_MATURITY for it, distinct from both a lock-up (which ends on the holder's own date) and a cycle (where something actually settles). Scoped to FIXED_MATURITY and pinned by tests in both directions, since the two deployed epoch pools are on-request and genuinely do settle before maturity. The shared 3-state model is untouched: it does not know redemption_type, and teaching it would move every FIXED_MATURITY derivation at once. A fixture for the state, which no seeded account can hold, is on /styleguide/portfolio.

Docs: 14-decisions v3-132 · 11-db-schema → pools. Migration 0189 (pending dev apply). Source: JY (product), 2026-08-15/16.

📅 August 15, 2026 — A pool that repays over several cycles after maturity turns out to need no new setting, one guard removed and one contract fix (v3-131)

🔧 Decided, not built — docs record the plan, no code has changed

A new product shape: redemption refused until maturity, then principal repaid across several cycles, with the coupon continuing on outstanding principal throughout. Written in settings it is redemption_type = FIXED_MATURITY + epoch_duration_days > 0 — combination ② of the new segment × mode matrix — and the contract already allows it. RedemptionLib refuses exits before maturityDate, the epoch engine settles on the cadence, and neither is aware of the other. The only thing refusing the combination is v3-88 D7, an admin-wizard guard that zeroes the cadence for FIXED_MATURITY because a matured pool "pays out once" — true of every pool that existed when it was written.

A third redemption_type value was rejected on precedent. redemptionConfig is set only in initialize and PlatformPool is not upgradeable, so each pool is pinned to the implementation it was created with. The last third value makes the point: LIQUIDITY_WINDOWS shipped on-chain with a deploy worker that never populated its window array, and the four pools created that way are permanently unable to redeem — retired rather than relabelled, because rewriting the column would have the DB assert a capability the contract does not have (migration 0106).

Nothing deal-specific is stored, including the instalment. Schedule length is per pool, and the per-cycle amount is not configured anywhere — each cycle settles whatever the publisher funded for it, pro-rata, so uneven instalments already work with no setting. What the schedule genuinely lacks is a termination: anchor and cadence exist, an end does not (redemption_window_epochs, spec only, MVP inclusion undecided — stored as cycles, entered in months, NULL = unbounded so existing epoch pools are unaffected).

The MVP is two items of five. (1) the wizard accepts the combination and relabels the schedule block "post-maturity redemption" — FE/BE only, no schema or contract change; (3) yield accrual moves from request to settlement, a contract change. The rest: (2) the cycle counter is undecided, (4) per-investor maturity is out of scope, (5) holders who never request is operations.

(3) reverses v3-91 Rule 1 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 problem is not that the requester loses a little — the publisher's coupon does not shrink, so the forfeited share goes to the holders who stayed. Against an agreement that says the coupon continues exactly as before maturity, the platform is not honouring the term; on a multi-cycle schedule it recurs every cycle, and requesting the whole position once forfeits the whole schedule. Accrual now ends at executeEpoch — not at claim, which would pay investors for delaying their own claim, and not at request. An unfilled remainder keeps accruing, since that principal is still deployed. Three exclusion segments sit between request and claim; settling on executeEpoch closes all three, including the one created by executeEpoch not burning LP. Both defences v3-91 relied on are untouched: epoch pricing is forward, and cancellation is already impossible after the cutoff.

Two stale doc claims corrected while writing this up. 07-redemption said both the lock-up and maturity boundaries are per position; 04-pool-models derived an investor's maturity as deposit_time + maturity_days. Maturity has been pool-level since migration 0161 — an absolute pools.maturity_date mirroring the contract's maturityDate, whose column comment says in as many words not to re-derive it from invested_at. The contract has lockupDays (a duration) and maturityDate (a date) and no maturityDays, so there is nothing to anchor per holder. Both pages now state the asymmetry and link the decided-but-unbuilt fix; the interim control is a ~2-week raise window.

Also rejected, each recorded: a coupon-basis setting (one option only), business-day conventions and holiday calendars (only the publisher's recall touches a bank, and recall_lead_days covers it), a weighted-average maturity anchor (no early exit means no penalty means no arbitrage), and redemption_gating_bps on this combination (a second cap can only break a schedule the publisher is already funding to). Holders who never request are sequenced rather than built — notifications, then a termination clause in the terms, then only as much code as the clause requires, and never the wind-down path, whose permissions look convenient and whose meaning is inverted.

redemption_type also gets its first proper entry in 04-pool-models — it has been an on-chain, create-only dimension all along and was missing from the dimension index.

Reviewed against the code afterwards, which moved two counts and one estimate. The decision-card count was corrected 123 → 127 (the old number was three stale, not one). This page's own total was corrected 151 → 150: the figure had been counting raw <details> blocks, one of which is a nested sub-toggle rather than an entry, and the same off-by-one was inherited rather than introduced. 04's Investor Terms intro was likewise a count behind its own table and is now eight. Item (1) was described as a guard removal; it is three coupled guards in pool-create/payload.ts, and lifting the cadence without the anchored-schedule block yields a pool the create endpoint rejects — both 07 and the card now say so, and 07 gained the recall_lead_days + request_window_days < epoch_duration_days bound that setEpochSchedule enforces. The substantive claims held: the enum values, the maturityDate == 0 refusal, the settlement-fund formula, the burn site, the permissionless fallback and the v3-88 D5/D7 citations all check out against the contracts.

Open with the publisher: coupon basis, what "9% monthly" denotes (annualisation, frequency, day count), whether principal and interest share a date, and missed-date handling. 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 — this product should disable it).

Docs: 14-decisions v3-131 · 07-redemption · 04-pool-models · 16-timeline. Notion "풀 상환 모델 — 구간 × 모드 (기획서)" + "만기 기준을 풀 단위 → 투자자별로 전환". Source: JY (product), 2026-08-15.

📅 August 13, 2026 — The landing site's breakpoint system reached the apps, and half of it was the wrong half to copy (v3-129)

✅ A big screen earns more content, not bigger content

The shared type scale's five display sizes are now fluid and shrink-onlystat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48 over a 375→1280px viewport, each clamp's max being the fixed value the token already had. Nothing renders larger than it did. label/data/read stay pinned at 12/14/16. On the layout side one new token, --container-monitor (1680px), lands the 2xl rung: web's column widens to it, while admin's main gains a cap it never had. web's browse grid also gains a fourth card, but keyed to its container rather than the viewport (below).

Reading the landing first changed the plan. It has two systems, not one. The main page (widgets/landing/*, 8 files) hand-writes a five-step ramp per element; the four pages that arrived from ch/product (20 files) use clamp() tokens declared once in global.css. The table that was circulating documents the older one. With 84 component files in web and 118 in admin, the per-element form is not affordable — and the landing's own drift from the first form to the second is the argument.

The apps were not un-responsive, they were responsive on one axis. Shell switches (web at md, admin at lg), 35 responsive grids, table overflow and a four-step gutter ramp were already there. What was missing was type and density: the eight-token scale was fixed px at every width, and a 48px hero sat unchanged on a 375px phone.

The monitor rung moves the two apps in opposite directions, which is the part that reads backwards. web was capped at 1280px and spent half a 2560px display on empty gutter. admin had no cap at all, so the same display stretched a seven-column Pools row across ~2496px and pairing a pool name on the far left with its chain on the far right became an eye-trip. Same token, one widening, one narrowing.

The three-competing-type-systems problem is smaller than it counts as. 111 large-type call sites moved onto the scale (default rungs plus arbitrary 21–40px values, max deviation 2px). The ~1,023 text-xs/text-sm/text-[11px] sites were left alone deliberately — 12–14px should not scale — and six text-4xl sites stayed because they size a decorative glyph, not type.

⚠️ The browse grid lost its viewport breakpoints above lg, and that fixed a bug older than this change. AppShell puts a 240px sidebar left of the routed content at lg+, so the cards live in viewport − 240 — and it collapses at runtime. Every viewport-keyed column step was wrong in the same direction: xl:grid-cols-3 fired at 1280 while the column held 912px and dealt 288px cards, and the new 2xl:grid-cols-4 dealt 274px. Both were narrower than the 368px the previous column count already gave, so each step labelled "more columns" was quietly making cards smaller. It reads lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] now — as many 300px-or-wider tracks as actually fit — with auto-fill rather than auto-fit, which would collapse tracks a short list cannot fill and stretch two cards to ~760px. 300px is a real trade, not a free win: a column is lost between 1280 and 1315 (cards 288 → 444), and a fourth arrives from 1640. portfolio's "Discover more" grid stays on plain columns because it renders .slice(0, 3).

⚠️ 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.

⚠️ Two traps worth carrying forward. Fluid tokens are rem + vw, never bare vw, or browser text-zoom stops working. And Tailwind emits variants after base utilities, so p-4 sm:p-6 lg:p-8 pb-20 silently loses its pb-20 above sm — ramp the axes instead.

⚠️ Line-height came along with the size. The token scale runs 1.05–1.1 against the 1.2–1.4 of the rungs it replaced: right for a single-line figure, cramped for a heading that wraps. The 30 converted titles with no explicit leading-* gained leading-tight.

Deferred: phone support for admin/FM. The lg: drawer stays as built; admin is laptop-and-up for now.

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 bundles and 2xl:grid-cols-4.

Refs: v3-129 · 25-design-system § Breakpoints and the page column

📅 August 13, 2026 — Aset stopped granting itself the partner's role, and the two endpoints that depended on it went with it (v3-53)

✅ The shim was one line in the deploy path, not a design

create-pool.ts granted the platform signer YIELD_DEPOSITOR_ROLE on every pool it deployed. That role gates the partner's two money-IN functions, depositYield and fundRedemption, and initialize already grants it to _fundWallet — so the partner could always sign them. The second copy enabled nothing the partner needed; it let Aset sign in the partner's place, which 09-rbac had flagged since v3-53 as a dev shim that "must be removed before mainnet — it is custodial". It is removed, and ORACLE_ROLE — the one role the docs assign to a hot automated key — is untouched beside it.

The two server-key routes came out with it, because the grant was the only reason they could work: POST /redemption-requests/{id}/fund is deleted, and POST /pools/{id}/epoch-funding keeps only its fund_tx_hash branch. Neither loses a capability — record-funding and epoch-funding + fund_tx_hash are the partner-signed equivalents, they verify the receipt rather than trust it, and admin-web already refused the server path on any pool with a fund_wallet (fmSignBlocked, fund-payout-button.tsx). Since fund_wallet is required on every real on-chain pool (v3-26), that was every pool a redemption can exist on. The two now-dead wrappers (fundRedemptionOnChain, depositYieldOnChain — the latter had already lost its last caller when deposit_tx_hash became mandatory) are gone from lib/shared/contract/.

No new grant mechanism was added, and none is needed. The role tracks the wallet on its own: initialize grants it to fund_wallet, executeFundWalletChange revokes the old holder and grants the new one (PlatformPool.sol:1413-1414). Granting per-FM at registration was considered and rejected — the role is per-pool on immutable Clones, so it would need a transaction per pool per FM, each one signed by DEFAULT_ADMIN_ROLE, putting a routine admin event on the key v3-37 assigns to a cold 3-of-5 Safe.

Guarded, because the failure mode is silent. The grant list is extracted as buildOperationalRoleCalls and asserted in create-pool-config-calls.test.ts: re-adding YIELD_DEPOSITOR_ROLE fails two tests. It deploys once per pool into a non-upgradeable proxy, so a regression is not fixable by redeploying the backend — and "the backend cannot fund a redemption" is exactly the report that would tempt someone to put the line back.

⚠️ Pools deployed before today still hold the extra grant on-chain. New pools are clean; existing ones need revokeRole(YIELD_DEPOSITOR_ROLE, platform) per pool.

📅 August 13, 2026 — A dormant lever came out, and the five formulas that had been carrying its bucket got shorter (v3-128)

✅ A capability nobody pulls still costs every formula that carries it

The partner-remainder hold-back is removed in full — contract storage, setFundingRestricted, _releaseHeldFunds and its clamp, both FundReleases* events, the two 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() has one arm now: the partner remainder always transfers to fund_wallet.

This reverses v3-112, which had weighed "declining to build the product path" against "deleting the mechanism", judged deletion irreversible and expensive, and priced the retention at nothing. The retention cost was not the lever, it was the bucket: heldFundReleases was a term in the wind-down NAV numerator, the epoch available-liquidity sum, the instant settle gate, the epoch fill debit order and the claim drawdown — five money formulas, carried in the contract, the fold, the off-chain mirrors, both front ends and these docs, for a value that was zero on every pool that has ever existed. Two consecutive money-path reviews spent time establishing that the term was dormant before setting it aside.

Verified before deleting, not assumed: fundingRestricted false and heldFundReleases zero on all seven deployed pools (read on-chain), zero HELD_FROM_PARTNER / HOLDBACK_RELEASED rows in the ledger, no projection with a non-zero bucket, and setFundingRestrictedOnChain with no caller in any lambda, scheduler or admin screen. No number on any pool changes.

The open question dies with the code. v3-112 had routed one to the contract owner: _releaseHeldFunds subtracted three obligations where the backing invariant counts five, so a release could have carried out balance backing holder yield claims. It was latent because the lever was dormant; it is now moot.

⚠️ Contract redeploy required, and clones do not follow. Pools are EIP-1167 clones with the implementation pinned at creation, so 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 failure is intended: such a pool needs this reverted, not patched around.

Enum labels stay, deliberately. Dropping HELD_FROM_PARTNER / HOLDBACK_RELEASED from money_event_kind means rebuilding the type and re-casting the column, and four views read money_events.kind — one rewritten days earlier in v3-126. Copying four view definitions into a migration to delete two labels trades a real risk for none. A CHECK makes them unwritable; events.test.ts declares them in the RETIRED list the file already had.

Verified: contract 453 tests · infra 569 · money packages 157 · four build guards · replay 6 clean / 0 differing · ABI regenerated (115 lines lighter) and --check clean.

Refs: v3-128 · migration 0183 · 23-money-path §6

📅 August 12, 2026 — The KYC edges invented a jurisdiction and collapsed an identity state, and an on-chain gate honoured both (v3-127)

⏳ A credential is only as trustworthy as the weakest translation at its edges

A review of the whole KYC flow turned up two defects at the SBT boundary, same shape: one wrote an identity fact nobody had verified, the other read one that had lost its distinctions. Neither errored.

mintKYCSBT defaulted countryCode to 'KOR', and the caller passed it only when the mirror on users was non-empty — so a holder whose country was never resolved received a credential asserting Korean jurisdiction. That string is the input to both on-chain investor gates (jurisdictionAllowed[jurisdictionHashOf(investor)] and _isUsCountry), while the off-chain gate fails closed on an unknown country. The two sides disagreed in the dangerous direction, and deposit() is callable without passing the off-chain one. Two live paths reached a null mirror: a GREEN review whose applicant fetch failed, and any row approved by an on-chain self-heal. The country is now required with no default (omitting it is a compile error), resolved through the same helper the deposit gate uses, backfilled from SumSub once, and an unestablished country fails the mint closed — before the re-certification burn, so a refusal never leaves the holder with nothing.

POST /auth/verify read isValidKYC, which is kycStateOf == VALID, so EXPIRED and REVOKED both arrived as the same false as never-verified and took the downgrade branch. A revoked holder's kyc_status = REJECTED was overwritten with NOT_STARTED, and isFinalRejected keys off REJECTED — so signing in cleared an AML ban and re-opened KYC. An expired holder dropped to GUEST/NOT_STARTED, which POST /yield-claims reads as "no KYC": the off-chain gate refused a withdrawal canRedeem explicitly allows (v3-28). resolveSbtSync now takes all four states plus null and writes only the columns it has an opinion about; checkValidKYC was deleted.

Verifying it turned up five more. "One mint in flight" was checked only in the force branch of POST /kyc/mint-sbt, read-then-write, so the plain path could queue a second mint that burns the token the first just minted — now an atomic claim inside enqueueSbtMint, shared by every caller. isFinalRejected read the latest kyc_logs row of any kind, so an applicantDeactivated after a RED/FINAL put a reject_type-less row on top and the terminal ban stopped applying. .single() made a failed read indistinguishable from a deleted user, and both permanent. A renewal kept the old sbt_expires_at, so a failed re-mint left the reverify sweep quoting a burned credential. And the KYC poll's terminal-state list had omitted NOT_SUBMITTED, so every signed-in visitor who had never started KYC polled every 10s forever — three queries and an RPC per call; the predicate now enumerates the waits instead.

🔴 Existing data. Any SBT minted while the country mirror was empty carries KOR on chain and needs a force re-mint. No migration can correct an on-chain attribute.

Also shipped: one verification gate shared by /kyc/access-token and /kyc/reuse (reuse had refused every approved holder, so an expired one could renew through the SDK but not by importing the partner applicant they arrived with), the applicant mirror unified across every path that fetches an applicant, and 0183kyc_logs is read newest-first on both paths and the admin list was sorting the whole table unindexed.

📅 August 12, 2026 — A dropped table's defaults outlived it: the redemption view called "awaiting funding" done, and dropped every request that was never held (v3-126)

⏳ Status derived from events is only as good as which event it reads

The admin Redemptions screen was reported as "still half-rendered". It was not: the screen was complete and the data underneath it was wrong in two ways, both because money_redemption_list inherited assumptions that were true of the dropped redemption_requests and false of money_redemption_workflow.

Its status branch read WHEN fund.id IS NOT NULL THEN 'PENDING_RESERVE', where fund is a REDEMPTION_FUNDED event — which fundRedemption() emits, and which the instant path only accepts once the request is already PENDING_RESERVE on-chain (RedemptionLib.sol:566). So it labelled the moment the wait ended as the wait itself, and labelled the actual wait REQUESTED. The state that is genuinely waiting has no ledger event at all — RedemptionPendingReserve is not a money_event_kind — so the only witness is funding_shortfall, a column the view was already joining and not reading. Everything keyed on PENDING_RESERVE therefore saw the complement of the set it wanted: the funding hero, the obligations timeline, the by-pool breakdown, the Fund-all list, and /dashboard/stats's platform "awaiting funding" total. PROCESSING was absent from the CASE entirely, which made a live branch in deriveFundingStatus dead code.

Separately, funding_status lost its DEFAULT 'FUNDED' in the move and is now nullable with only three explicit admin writers, so a plain new request carries NULL — and the list endpoint's funding_status <> 'HELD' evaluated to NULL, which WHERE discards. The epoch Open and Rollover queues returned nothing for ordinary requests while the demand aggregate beside them, a separate SQL RPC, kept reporting the LP: a total with no rows under it. Fixed as an is_held boolean in the view rather than a predicate in each caller.

Verifying it turned up two more of the same family. The per-request FM notice sent payout_amount as the shortfall — null for a request that by definition has not been paid — so Number(null) = 0 had been shipping "[Action needed] Reserve shortfall … needs 0 USDC top-up". And readPoolHolders filtered lp_balance > 0, which is right for LP and wrong for yield: banked accrued_yield survives a full exit and claimYield has no LP precondition, so the receipt's unclaimed meter read low and the yield reconciler never checked those holders against the chain at all.

Also shipped: GET /redemption-requests/instant-summary and POST /pools/{id}/notify-fund, the two endpoints four admin surfaces had been waiting on behind disabled + a reason.

🔴 Deploy order. The Lambdas filter on is_held and PostgREST returns 400 for a column a view does not have, so 0180 goes first or an empty queue becomes an error.

Docs: v3-126. Migration 0180_redemption_list_status_and_hold_flag.sql. Source: JY, 2026-08-12.

📅 August 12, 2026 — The portfolio stops promising a gain the schema forbids, and says when the money comes back (v3-125)

⏳ Principal is capped at par, so the position delta is a writedown and the return is the yield strip

/portfolio was rebuilt to the agreed IA (summary → liquidity → positions → recent activity → discover), and doing it surfaced that the screen was designed around an outcome the database forbids. pools.nav_per_token carries CHECK (<= 1.000000), and so does nav_history.new_nav, because NAV prices principal only and yield is claimed separately. A deposit mints at 1.0, so tokens × NAV can never exceed its cost basis, yet the hero, the Value column and the row detail all painted a green +$0.00 (+0.0%) under labels that read as investment return. v3-125 renames the figure Principal change, states flat in words ("At par, no change"), and keeps the positive branch for the one case that reaches it: entering below par on a transfer-in or secondary holding, where the row now names the reason instead of showing an impossible-looking number.

Two fields the FE believed in do not exist. nextSettlementAt and expectedFillPct are on PoolRow but in no migration and in neither pools select (grep next_settlement_at apps/infra → 0 hits), so both are permanently null. The redeem dialog read the first one and therefore fell back to "every N days" for every epoch pool, including ones with a confirmed funding date. The real anchor is next_funding_date, which LIST_SELECT carries; the portfolio badges it expected, since the two provenance columns that would make it confirmed are detail-only. Also fixed on the way past: the per-cycle cap was disclosed in raw bps against v3-73, and NAV display is now 2 decimals everywhere, matching what position-card.tsx and computePoolRiskTier already did.

Separately, a crash class closed. Intl.DateTimeFormat.formatToParts() throws RangeError on an invalid date, and the date formatters are called straight from render, so a single unparseable timestamp in any payload reached the root error boundary and replaced the entire app with "Something Went Wrong" — no shell, no page, nothing naming the field. One bad value now costs one dash. The liquidity timeline also stopped emitting markers with non-finite timestamps; its settlement and maturity branches were guarded and lock-up was not.

What is deliberately absent. No wallet idle balance (no balance hook), no portfolio value-over-time chart (per-pool nav-history exists, an aggregate endpoint does not, and summing them client-side would ignore holding periods), no interest/fee/writedown split of yield (claimable_yield is already net), and no per-wallet holdings (money_positions is keyed by user) — replaced by an LP-state split: available / in redemption / locked / redeemable in full.

Composition colour was run through the validator rather than chosen: a single-hue ordinal ramp (brand-700/500/400/300), because the DS has no categorical set that clears CVD separation and status hues would read as good and bad news on asset classes. The previous 600/500/400 failed the adjacent-lightness check, which is exactly why the segments were indistinguishable; four steps is the ceiling, so a fifth group folds into "Other" by size. Behaviour-claiming copy went to shared/copy/portfolio.ts per v3-96, and the /investor-activity row → ledger mapper moved to shared/lib so the Activity page and the portfolio's recent-activity section cannot drift on the payout-vs-LP amount rule.

Verified: tsc -b clean, production build clean, copy guard clean, 26 tests passing. No BE change, no migration, no contract change. /styleguide/portfolio renders the real widgets against fixtures, because the four position states cannot coexist on one account. Still open: the zone row-count imbalance is cosmetic and unresolved, and the new widgets' mobile layouts have not been reviewed narrow.

Docs: v3-125. Source: JY (product), 2026-08-12.

📅 August 12, 2026 — "One month" gets one meaning, and the epoch wizard stops hiding two thirds of the cycle (v3-124)

⏳ Three time bases, named once, so the 28-vs-30 argument stops recurring

"Is a 1-month lock-up 28 days or 30?" had been re-argued repeatedly, each time starting from the assumption that one of the numbers was wrong. None of them is. v3-124 names the three mechanisms instead: the epoch cycle is a fixed 28 / 84 days because the contract advances by epochDurationDays × 1 days and does no calendar arithmetic; yield distribution is a calendar month, 12 a year, end-of-month clamped; lock-up, maturity and duration display are 30 days to a month. The operative rule: 28 exists in exactly one place, and every value called a "month" is 30. Reconciliation table lives in 26-glossary → month, alongside the 분모 collision it resembles.

No constant moved. The cadence stays 28 / 84, yield stays on the calendar, the firstYieldIntervalDays floor stays 30 / 90, and epoch_schedule_type keeps its MONTHLY / QUARTERLY values. The admin cadence selector now reads Every 28 days, which is what frees "month" to mean one thing; lock-up and maturity gained a months/days unit toggle that converts once at the boundary, like percent → bps.

Two regressions are written down as guards rather than left to be rediscovered. Lowering firstYieldIntervalDays to 28 would end a lock-up two days before any yield accrues, so a YIELD_BASED early exit forfeits $0 — the v3-84 hole. Moving the cadence to 30 / 90 costs weekday stability (30 mod 7 = 2), sits exactly on MAX_EPOCH_DURATION_DAYS, and splits the estate because setEpochSchedule is create-only.

Also — the epoch timeline was drawing 17 of 28 days. It stopped at the funding date, which read as though a cycle were window + lead and the next 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 (cadence − window − lead) that nobody enters and nothing displayed. The wizard now spans a whole cycle and names that leftover, and 07-redemption's ASCII diagram was corrected the same way — it had labelled the recall lead "the gap" and omitted the leftover entirely. One derived-value bug fell out: the "earliest anchor" hint quoted now + span, whose window opens at that instant and is then rejected as already past, so it named a date the form refuses; it is now the first date actually accepted, and the inline fix uses the same value.

Verified: admin-web tsc -b clean, production build clean, copy guard clean (228 files), 150 tests passing (18 new, covering the leftover, the next-window derivation and the month converter's round trip). Also confirmed the new timeline utilities emit into the built CSS rather than silently rendering transparent. No BE change, no migration, no contract change. WEEKLY was left out of the documented yield_frequency set on purpose: the backend recognises it but nothing in the product can set it.

Docs: 26-glossary · 07-redemption · 04-pool-models · v3-124. Source: JY (product), 2026-08-12.

📅 August 12, 2026 — The Yield review can sign its whole priced set, and stops claiming holders were paid (v3-123)

⏳ One runner for both money paths, and a counter that does not overstate settlement

The Yield review priced every due pool and ended at a permanently disabled button whose tooltip said one operator cannot sign the whole set. That stopped being the reason on August 11: A-2 shipped a runner that walks a list signing each item, and the modal was already FM-only, so one fund wallet can sign all of its own pools. v3-123 extends use-fund-all-flow with a YIELD_DEPOSIT kind rather than forking it — the loop, the per-item state machine, the ref-based double-pay guard, per-item chain switching and the signer pre-check are domain-free and already proven on redemption funding. Only the pool function and the fact that recording yield takes two server calls differ.

Three places the yield path is deliberately not the redemption path. A zero amount fails instead of completing: instant funding marks zero done on purpose (the reserve moved, re-sending would pay money nobody is owed), but a yield row at zero is one the FM has not priced, and done is the state that suppresses retry. The plan is snapshot when the run opens, because recording a distribution invalidates the due queue — read live, each success would delete its own row from the stepper and a full run would end on an empty dialog. And a deposit whose settle fails stays done with a message pointing at ops, since the deposit is irreversible and failed invites a second one.

That last case is why the progress counter reads "deposited", not "distributed": done includes the item whose settle failed, so "2 of 4 distributed" would sit directly above a row saying holders have not been paid. Understating settlement is recoverable; overstating it is the failure the stall message exists to prevent.

Also corrected here: 13-operations claimed the stalled-distribution notification was "not built — blocked on copy". It shipped on August 4 (yield.scheduler.stalled.ts, daily-bucket idempotency, opsTeam only because recovery is ORACLE_ROLE-gated) with both in-app and email copy.

⚠️ Shipped unproven on the money path. tsc -b clean (admin-web + web), admin-web build clean, copy guard clean (227 files — it caught a "records list" against v3-96 and a prose em-dash), four render states with no console errors, Redemptions dialog unchanged (Fund all $16,400 / 1 of 3 funded). The signing path 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 cannot be arranged on demand, and it is what caught the counter defect above. No BE change, no migration, no contract change — FE only, so the FE pipeline does ship it. First real use should be one or two due periods, not a full queue.

Docs: 13-operations · v3-123. Source: JY (product), 2026-08-12.

📅 August 7, 2026 — Yield stops being a table you read and becomes a queue you work (v3-120, v3-121)

⏳ The due queue now starts a week early, and says which rows are actually late

due-rows.ts selected next_yield_due < now, so a period first appeared in the work queue on the day it was already overdue — for an action that needs money in the fund wallet and an on-chain signature. v3-120 widens it to now + 7d, deliberately sitting before the notification pipeline (which waits 3 days past due, then re-sends every 7) rather than overlapping it.

The risk was not the window; it was the two figures that summed the whole queue. The exposure card and the "N pools overdue" count were correct only while the queue was overdue-only, and widening it would have folded not-yet-owed money into a number labelled overdue with nothing failing. Every due row now carries is_overdue, and it is the only correct filter: overdue_days is clamped at 0, so an approaching period and one due today both report 0. The clamp stays (the CSV already shipped D+${overdue_days}), but that column now renders from getDDay so it no longer exports "D+0" for everything not yet late.

🧾 Two new reads: which fund to chase, and who actually got paid

v3-121 adds GET /yield-distributions/fund-summary (one row per fund: owed, pools behind, next due, FM-notify state, worst first) and GET /yield-distributions/{id}/investors (per-investor split, plus the pool's unclaimed balance). Both are reads of records the system already writes — run-distribution.ts has persisted the per-holder split since it shipped and nothing read it back. The rollup is server-side rather than grouped in the browser because the screen renders the exposure card and the By-Fund totals side by side, and two independent sums of the same money drift apart.

🔴 The FM-notify state has no column, and both fields that look like one are wrong. yield_distributions.fm_notified_at is stamped when a distribution runs, so it is null for exactly the unpaid periods the column describes; POST /pools/{id}/escalate-yield writes no DB row at all. The record is the notification_events row escalation leaves behind. And reading that alone would have shipped a lie: dispatch.ts inserts the event before resolving the audience, so escalating a fund with no FM leaves a "notified" trace for a message nobody received. Joining notifications separates UNREACHABLE from NOTIFIED, which is the whole reason the column exists.

Three dead columns confirmed dead. yield_distribution_investors.investor_name / .investor_address have no writer (names come from the users join), and .claimed_at has none either — which is why the receipt's claim meter is pool-level and cumulative, built on portfolio_positions.accrued_yield, and labelled that way. Per-round claim state is not recorded anywhere and is not invented.

🎭 The screen now splits by role, because only one of the two roles can pay

Admins and operators never hold a fund wallet, so depositYield is unsignable for them — yet the header Distribute button, the row actions and the batch review were all offered to them, each ending at a blocked submit. FM sees a Due now card (overdue first, with the fund wallet's balance read on-chain) and an Upcoming timeline; admin sees an overdue exposure card and the By-Fund table, and every admin action is Escalate to FM. The one exception is unchanged: recovering a stalled PROCESSING row via settleYield, which is Aset's ORACLE_ROLE. Terminology follows v3-96 — "Record" is gone, the single action is Distribute, "signature" is never surfaced.

Docs: 13-operations · 15-api-reference · v3-120 · v3-121. Verified: tsc -b clean (admin-web), copy guard clean (212 files), notification-producer guard clean, 445 infra tests green. No schema change, no migration, no contract change — both endpoints are reads and is_overdue is computed in the read model. ⚠️ The two endpoints and the lead window are backend, so they need pnpm --filter @aset/infra deploy:dev:api; a green FE pipeline does not ship them. Source: JY (product), 2026-08-07.

📅 August 6, 2026 — The FM can fund an epoch cycle, and two KYC-gate claims turned out to be wrong (v3-119)

💸 The contract was already funding cycles; the API was the only thing that needed a request id

On an epoch pool Pool.fundRedemption never touches the request it is handed — the epoch branch credits epochFundTopUp[currentEpochId] and returns, using requestId only in the event it emits (RedemptionLib.sol:444-459). The single route to it was POST /redemption-requests/{id}/fund, so a cycle-sized transfer had no honest target, and the admin Epochs tab shipped the FM's one action disabled with a note pointing at the request rows. POST /pools/{id}/epoch-funding (v3-119) closes that: same two custody paths as before — amount (platform key, viable only where the fund wallet is that key, since fundRedemption is onlyRole(YIELD_DEPOSITOR_ROLE)) or fund_tx_hash (FM-signed; the receipt is verified to have succeeded and touched this pool rather than trusted). requestId goes out as 0: nothing consumes RedemptionFunded off-chain, and a borrowed id would make the log claim a request was funded when none was.

Nothing is written to redemption_requests. The credit belongs to the cycle, so stamping its N queued rows with one funding tx would misattribute it N times and overwrite each investor's own request hash with the partner's transfer. The record is a new POOL_EPOCH_FUNDED activity event plus the chain, and the response reads the cycle back so the caller can tell "funded, still $X short" from "funded and covered" — returning snapshot_unavailable: true instead of inventing numbers when that read fails. Part-funding stays supported (short delivery fills pro-rata and carries), so the control defaults to the shortfall and lets it be edited down.

🔴 The anchored-pool deploy failure was in the gas estimate, and the anchor had nothing to do with it. An epoch pool deployed from the wizard died with setEpochSchedule reverting InvalidSchedule(), and the standing theory was a contract rule requiring the anchor a full cycle out. There is no such rule: the fourth guard compares an absolute timestamp against a duration in seconds (17 days ≈ 1.47e6 against an anchor of ≈ 1.79e9), so it cannot fire on a real date, and the wizard was already stricter. What fired was recall + window >= epochDurationDays reading 17 >= 0s.epochDurationDays has exactly one writer (GovernanceLib.sol:113) and the clone's initialize is not it. create-pool.ts did queue that setter first and said so in a comment, but the ordering only ever governed execution: all config calls were gas-estimated in parallel against the un-configured clone, and an estimate reverts exactly as the real call would. So the deploy died in pre-flight, no transaction sent, with the receipt trail pointing at the wrong call. The first fix staged the pre-flight (calls carry a stage, stages mine in sequence, calls inside one keep pipelining) and was superseded on merge by the in-flight-limit finding below: the ops key is an EIP-7702 account capped at one pending transaction, so a staged batch fails at submission just as the fully parallel one did. createPoolOnChain is now fully serial — estimate, send, wait, next — which fixes both causes with one mechanism, since each call is measured against the state its own send will execute in. ConfigCall.stage / planConfigStages dropped as redundant; buildEpochConfigCalls kept, because array order now binds the simulation too. Estimation failures are re-thrown with the function name and "no tx sent", and the post-deploy read-back asserts epochDurationDays too.

Left alone on purpose: lifecycle_status = ACTIVE next to deploy_status = DEPLOY_FAILED is the intended pair, not drift. Lifecycle is the publish intent, deploy_status is whether the chain caught up, and Retry Deploy needs the first preserved to land on the original target (pools.post.lifecycle.ts:62-78). It is already contained: pool_address is written only on success, investors are filtered on both API and client, and the admin chip shows Deploy failed ahead of the lifecycle chip.

🔴 Two things we believed about the KYC exit gate were wrong. The epoch claim does not revert RedemptionBlockedByKyc — it raises KYCRequired (RedemptionLib.sol:1049), and RedemptionBlockedByKyc belongs to the instant settlement funnel (:544) and the permissionless fallback (:416). A mapping added against the wrong name compiles, ships and silently never fires. And NONE was never documented at the gate at all: canRedeem is VALID || EXPIRED (PlatformKYCSoulbound.sol:371-373), so NONE is refused exactly like REVOKED, while v3-19 named only the EXPIRED-passes / REVOKED-blocks pair. 03-kyc-identity now carries all four states and a per-path revert table.

Also in this pass, on the investor side: the redeem modal previously promised cancellation "any time before settlement", which the contract refuses twice over (window gate + ClaimBeforeCancel) — replaced with the actual rule, alongside the A10 statement that the request escrows the LP and stops its accrual while already-earned yield stays claimable. The IMPAIRED page banner now names the loss recognition and the lock-up waiver instead of "we're monitoring the situation".

Docs: 15-api-reference · 03-kyc-identity · v3-119. Verified: tsc clean across web / admin-web / infra, copy guard clean (170 / 200 / 342 files), notification-producer guard clean, 531 tests green (22 web · 97 admin · 412 infra). New activity event type POOL_EPOCH_FUNDED. No schema and no contract change. Source: JY (product), 2026-08-06.

📅 August 6, 2026 — 04-pool-models becomes skimmable: an index of every dimension, and the deep parts fold away (v3-118)

🗂 A reference wall, turned into an index plus disclosure

04-pool-models now opens with one table listing all 29 documented settings — what each controls in a line, its mutability, its status, and a link to its own definition (v3-118). Learning which settings exist no longer means scrolling seven category tables and sixty sub-sections. Below it, six blocks of deep mechanism and three long YAML examples fold behind <details>.

Nothing was cut. v3-114 already moved 04's archaeology to its owners; this pass is presentation only, and <details> is disclosure-only — every line stays on the page and in the DOM, so Ctrl-F and site search still reach it. The diff is 83 insertions against 11 deletions, and all eleven belong to the two corrections described below.

Folded: tranche grouping mechanics · the deposit flow's 8 numbered steps (they restate the diagram directly above) · the wind-down ASCII timeline (it restates the mermaid) · the 60+30-day rationale · the risk-tier rationale and its phasing table · the three big YAML configs. Example 3 stays open — two lines of config is not worth a fold.

Anchors were the constraint. A native <details> does not reliably open on :target, so every heading and both <a id> aliases stay outside the fold and only body content is wrapped. 04 has 26 distinct inbound anchors (#emergency-wind-down alone has 13); all verified present in the built HTML, plus the 31 new index links.

🔴 Two stale things surfaced and were corrected. The seven category bullets carried counts (Asset Configuration (7), Operations / Advanced (1)) — five of the seven no longer matched their own tables. They are gone rather than re-typed: the index expresses the same thing and is built from the tables, so it cannot drift from them. And the wind-down mermaid label still read NAV = distributable/totalSupply, contradicting the correct formula in that section's own prose thirty lines above and the contract deployed 2026-08-04 (v3-116); it now reads distributable / claimingSupply.

The three flow sections were re-checked for material another page owns — every checkpoint already linked out, so none were touched.

Docs: 04-pool-models · 14-decisions v3-118. Verified: build green, check-doc-anchors 1304 checked / 2 broken (both allowlisted) / 0 unexpected. No schema, contract or product change. Source: JY (product), 2026-08-06.

📅 August 6, 2026 — A governance timelock could expire with nobody executing it, and a permissionless wind-down never reached the DB (v3-117)

⏳ Nothing runs impairment or wind-down on its own; the reminder and the mirror both existed only for NAV

A NAV decrease applies by itself — nav-changes.scheduler.apply-pending is a scheduled lambda calling applyPendingNavOnChain. Impairment and wind-down have no equivalent, and the resemblance is exactly what made this hard to see: executeImpairmentOnChain / executeWindDownOnChain have one caller each, the action === 'execute' branch of their own POST handler, and both handlers register with HTTP paths:, never a schedule:. So a 7-day or 30-day timelock could run out with the pool still in its pre-proposal state and nothing saying the decision had never been enacted. Two new opsTeam events — impairment_execute_due (D-1) and winddown_execute_due (D-3) — now come from an hourly sweep, pools.scheduler.governance-execute-due. They nudge; they do not execute. Impairment is irreversible and wind-down is terminal, so neither belongs to a cron. The lead window has no upper bound on purpose: one wide enough only for the lead period would be a single cron tick for impairment, so one failed run would drop the nudge, and the proposal that sailed past its expiry is the case worth catching. Firing once comes from the idempotency key ({event}:{poolId}:{proposedAt}), which also re-arms after a cancel-and-repropose.

executeWindDown() being permissionless is deliberate, not a bug — do not "fix" it. It carries no role modifier while executeImpairment() is onlyRole(DEFAULT_ADMIN_ROLE)-gated, and that asymmetry was filed as a security finding before the docs settled it: "T+90 days Timelock passes → anyone can call Pool.executeWindDown()". It is the standard pattern where anyone may trigger an already-approved, timelocked action; propose and cancel are admin-gated and the executor cannot alter a parameter. Adding a modifier would break the documented liveness design.

🔴 The real defect was underneath it. Permissionless execute is only safe if the execute is mirrored, and the indexer watched neither ImpairmentExecuted nor WindDownExecuted nor LifecycleStatusChanged — the sole writer of lifecycle_status = WIND_DOWN was the BE execute branch. An outsider could therefore wind a pool down on-chain while the DB still read ACTIVE, leaving the UI offering deposits the contract reverts: the v3-92 class, this time with a real trigger. All three are watched now. The two *_executed writers reuse the handlers' exact idempotency keys, so going through the API dedups to a no-op and only a bypass actually sends. is_paused is deliberately left alone (a third-party execute sends no unpause(), so the mirror must keep saying the pool is paused), and the pool_updates feed entry is the acknowledged residual — createSystemPoolUpdate has no idempotency key, so calling it here would duplicate the entry on the API path.

Two formatting fixes rode along, both user-visible. The 7-vs-30-day durations lived in four places with nothing linking them to the Solidity constants, which is how winddown_proposed_ops came to tell the fund manager "a 7-day timelock applies" on a 30-day window — a 23-day understatement on the one notice whose purpose is to say when to object. Both handlers now import business/governance-timelock.ts, and the new *_execute_due pair renders the computed expiry instead of naming a duration. Separately, nav_change_proposed had been passing the raw NUMERIC column into '{oldNav} → {newNav}', so investors read 0.9166666666666666 → 0.85; notifications/format-nav.ts is now the single NAV formatter across all six NAV events.

Still open: ImpairmentProposed / WindDownProposed are unwatched, so a proposal placed directly on-chain is invisible to both the propose notice and the execute-due sweep, which reads the DB columns. Verified: copy · producer (66 keys) · units guards clean, tsc clean, 412 tests. Docs: 22-notifications · v3-117. No schema or contract change. Source: JY, 2026-08-06.

📅 August 6, 2026 — One server transaction at a time: the ops key is an EIP-7702 account, and nothing knew it

⛓️ `--slow` becomes the default, in the scripts and in the backend

A forge script deploy failed with -32000: in-flight transaction limit reached for delegated accounts. The cause is not the RPC: the deploy key 0xdcd8662E…7764 carries an EIP-7702 delegation (its code is 0xef0100 + 0x63c0c19a…e32b, installed by a smart-account upgrade), and a node caps a delegated account at one pending transaction. DeployPoolImplementation sends six — five linked libraries plus the implementation — so the second was rejected. Nothing had landed (nonce unchanged, all six addresses codeless), so it was a clean retry.

That key is also ADMIN_PRIVATE_KEY. The same address signs every server-side transaction, which makes the cap a production concern rather than a deploy inconvenience, and it was invisible: isRetryableNonceError did not match this message, so an overlapping write failed outright instead of waiting.

  • Scripts: script/run.sh is now the single entry point (pnpm deploy:pool-impl, deploy:factory, create:pool, …). It sources the env file and always passes --slow, so the flag cannot be forgotten by whoever runs the next deploy.
  • Backend: writeContractAndWait treats the in-flight rejection as a submission-time error and waits it out (6 attempts, ~12s of backoff, budgeted separately from the nonce retries so a burst of one cannot starve the other). Within a handler it already awaited each receipt; this is what serializes writes across concurrent Lambda invocations, where no shared mutex exists.
  • Pool deploy, which was the broken one: createPoolOnChain deliberately pipelined its post-create setters — precomputed sequential nonces, all sent at once, receipts awaited together. Under the cap that fails wholesale, after paying gas for a half-configured clone. It now estimates, sends and awaits one call at a time. That also fixes a second defect the pipeline hid: setEpochSchedule validates recallLeadDays + requestWindowDays < epochDurationDays (GovernanceLib.sol:210), so estimating it up front — against a clone whose duration was still 0 — reverted InvalidSchedule on any anchored epoch pool (v3-107). Gas is now measured against the state each tx will actually execute in.

⚠️ The retry is a safety net, not the fix. Removing the delegation from the ops key removes the cap itself, and an ops/deploy key has no business being a smart account. Until then every server write pays up to ~12s of waiting whenever two overlap.

Every other Promise.all around the admin key was audited and is reads only, which the cap does not touch. Code: infra/lib/shared/contract/{client,create-pool}.ts · contract/script/run.sh · contract/package.json. Cover: lib/shared/contract/__tests__/submission-retry.test.ts. No schema or contract change.

📅 August 6, 2026 — Reinvest gets its one-line price fix, and positions finally record what they cost

💸 Two gaps closed on the same axis: what a holder pays, and what we remember about it

1. YieldLib.reinvest reads effectiveNav (v3-111 coded). Decided yesterday, now in the contract: lpAmount divides by PoolCommonLib.effectiveNav(s) instead of s.navPerToken, so a reinvest inside a queued 24h decrease mints at the announced NAV like a deposit does. Regression test test_ReinvestPricesAtAnnouncedNavDuringDecrease pins it — 1000 LP on the old expression, 1250 on the new one at an announced 0.80 — and the full suite stays green (PlatformPool runtime 20,835 B, unchanged margin; the change lives in the delegatecalled library, not the pool).

⚠️ Not deployed, and it cannot be retrofitted. It is absent from the 2026-08-04 implementation 0x27D9948F…f968, and a pool is a Clones proxy pinned to the implementation it was created with, so every pool that exists today still underpays a reinvest during a write-down window. It needs a new implementation + factory, then a newly created pool, before anyone can observe the fix.

The off-chain half shipped with it: yield.post.reinvest.ts now quotes the announced NAV in its LP cross-check (reading the queued nav_history row when has_pending_nav_update is set), because the chain-vs-estimate divergence check would otherwise fire on every reinvest inside a write-down wider than its 1% tolerance and report the stale applied NAV as a discrepancy. Valuation is deliberately left alone — effective_value still marks at the applied NAV, so one position is not written down 24h ahead of the rest of the pool.

2. Positions carry a cost basis (migration 0125, applied to dev 2026-08-06). Per-position P&L was not computable, because nothing recorded what was paid. effective_value is tokens × nav_per_token, rewritten on every position change, so it is always the current mark; entry_price (0013) was written only when a transfer created a position, never on the deposit path; and portfolio_positions.nav_at_investment, named in 05-investment-lifecycle since the beginning, has never existed in the schema.

entry_price now means weighted-average cost per LP token and every capital-in path maintains it: process_deposit_atomic, reinvest_yield_atomic, and the indexer's deposit / LP-transfer writers. Deposits and reinvests derive the price from amount / tokens minted rather than from p_nav_per_token — which is the point of contact with the fix above, since the applied NAV is not the trade price while a decrease is timelocked. Transfer-ins are marked at the pool NAV, there being no consideration observable on-chain. Weighted average rather than lots, because LP tokens are fungible and a position is one row per (user, pool); reinvested yield raises the basis rather than reappearing later as a phantom gain.

One rule is worth stating plainly: an unknown basis stays unknown. A top-up onto a position with no recorded basis leaves it NULL instead of adopting the new lot's price, so the UI shows no P&L rather than a fabricated one. The backfill follows the same line, behind two guards: no exit on record (deposits carries no per-row LP amount, so a partial redemption makes the reconstruction wrong) and cost ≤ tokens (NAV is capped at 1.0, so a reconstructed basis above 1.0 is proof tokens left by a path that leaves no redemption row — an LP transfer out). On dev it filled 20 of 31 positions, all at exactly 1.000000, and the second guard caught two rows the first would have missed: 9 LP against $10 paid in, and 49.95 against $50. Both are left NULL and are worth a separate look. Verified end to end against a throwaway Postgres loaded from schema.sql: 1.00 → 0.977778 after a top-up at an announced 0.80 → 0.968421 after a reinvest, unchanged by a partial exit, NULL preserved on an unknown-basis top-up, and both backfill branches taking the expected side. Pure helpers in lib/shared/business/cost-basis.ts with unit cover.

No API change (the portfolio endpoints select *), no new column, no front-end work — this makes the number computable, it does not yet display it.

3. Three findings from the same pass, fixed (migration 0126, applied to dev 2026-08-06).

  • The deposit handler had the identical misfire, and unlike reinvest it was live. deposits.post.create.ts cross-checked against the applied NAV while deposit() has read effectiveNav since the 2026-08-04 implementation — so every deposit inside a write-down wider than 1% was already being logged as a discrepancy that was only the stale price. Both handlers now call one shared readPricingNav (lib/shared/nav/announced-nav.ts), which is the off-chain mirror of PoolCommonLib.effectiveNav and is documented as pricing only: valuation stays on the applied NAV in both.
  • process_deposit_atomic now sets source = 'DEPOSIT'. The indexer's writer always did; the path almost every deposit actually takes did not, so provenance read "unknown" for the ordinary case. Insert only, and backfilled — all 31 dev positions now carry it.
  • Two functions nothing could call, dropped. revert_reinvest_atomic (0005) repaired a window that 0086/H2 closed by verifying on-chain before writing. The 8-arg reinvest_yield_atomic was the pre-0086 signature, kept alive as an overload; its sole caller passes all 11 params. That one was worth removing rather than leaving: it is the copy that does not maintain the cost basis above, so an 8-arg call would have skipped it silently. Both recoverable from 0005 / 0051.

Docs: 11-db-schema → portfolio_positions · 05-investment-lifecycle · 06-writedown-nav → R2·R3 · 23-money-path · 14-decisions v3-111. Code: contract/src/libraries/YieldLib.sol · infra/lambda/{yield.post.reinvest,deposits.post.create}.ts · infra/db/migrations/{0125_position_cost_basis,0126_position_source_and_dead_rpcs}.sql · infra/lib/shared/{business/cost-basis.ts,nav/announced-nav.ts,indexer/writers/{deposit,lp-transfer}.ts,indexer/resolve.ts}. Source: CH handoff, 2026-08-06.

📅 August 6, 2026 — Two front-ends, one refresh cookie: admin-web could log in as an investor and stay 403'd forever

🔑 The refresh cookie is now per front-end, and a missing admin row is a 401

An ADMIN reported POST /funds → 403 "Access denied", persistently. CloudWatch had the cause verbatim: RBAC lookup failed { code: 'PGRST116', details: 'The result contains 0 rows' }withRole could not find an admin_users row for the token's user_id, even though the account exists and is ADMIN. GET /funds had failed the same way 12 times that afternoon; nobody noticed because a failed useQuery renders as an empty list (admin-web has a MutationCache.onError but no QueryCache one), so only the mutation raised a toast and the bug read as "POST-only".

Root cause: web and admin-web share one refresh cookie. Both apps point at the same api host (VITE_API_URL is identical), and the cookie is host-only with no Domain — so refresh_token was a single slot that the later login overwrote. Once an investor login replaced an admin's cookie, admin-web's next /auth/refresh (it fires on any 401) returned an investor access token, which it stored and used for everything after. Every admin endpoint then answered 403, and 403 is not a re-auth trigger on the client, so the session stayed wedged until a manual logout. The panel still displayed role: ADMIN throughout, because that comes from the login response cached in localStorage, not from the token. The same signature was visible fleet-wide: 700+ RBAC failures in four days on /yield-distributions (a polling tab retrying every 30s), plus /config/chains, which only admin-web calls — proof the caller was admin-web running on a non-admin identity, not the investor app hitting admin routes.

Fixes (backend only).

  • One cookie per front-end (lib/shared/auth/cookies.ts): refresh_token_admin and refresh_token_investor. The pre-split refresh_token is still read so live sessions survive the deploy, is never written again, and is cleared on logout.
  • Audience resolution + role match (new lib/shared/auth/audience.ts): /auth/refresh picks the cookie by Origin against the injected ADMIN_WEB_ORIGINS, then rejects a token whose role does not belong to the calling app with a 401. This is the guard that makes the shared legacy cookie safe: a mis-resolved audience can only cost a 401, never hand an app the other side's identity. It is also what unwedges the reported session — the dead refresh now 401s into a real login instead of silently succeeding.
  • withRole answers 401, not 403, when there is no admin row (middleware.ts). No row means no admin identity at all (investor token, or a deleted admin) — authentication, not permission. withOptionalAuth already returned 401 for the same condition. Role mismatch keeps its 403, as does every fund-scoping 403. The log line now carries userId and tokenRole, which is what made this take log archaeology to find.

Investor endpoints are unaffected: /deposits, /redemption-requests, /portfolio-positions, /yield-claims and the pool reads use withAuth / withOptionalAuth, and no withRole-gated path appears in apps/web. Regression cover in lib/shared/auth/__tests__/refresh-audience.test.ts. Operational note: admins holding a poisoned cookie are logged out once on the next refresh and simply sign in again.

And the frontend gap that hid it, in both apps. Mutations have had a global error surface since the start (MutationCache.onError); queries had none, so a rejected read resolved to zero rows and the screen said "there is nothing here". Both apps now attach a QueryCache.onError twin, deduped per query key (released on the next success) so a poller cannot stack a toast every tick, with the same meta: { silent: true } opt-out. The toast is the notification, not the answer, so the lists say it too: a shared QueryErrorState ("Couldn't load X. This is not an empty list.") replaces the table on the seven admin list screens — funds and pools had no failure signal at all, and audit-log / deposits / KYC / redemptions / yield had a small red line above a table still reading "No records found", which is the same lie with a footnote. On the investor side, portfolio was the worst case in the product: a failed positions fetch rendered "You don't have any positions yet" to someone who holds positions. It now says the request failed and offers a retry. (web's pool catalogue already did this correctly and is unchanged.) Verified in the running apps with Playwright, not just by types: forced 500s on /funds and /portfolio-positions produce the error state, the retry, and the toast, with the auth-failure path still yielding "session expired" instead.

Docs: 15-api-reference. Code (infra): lib/shared/auth/{audience,cookies,middleware}.ts · lambda/auth.post.{refresh,logout,verify,admin-login,admin-oauth,admin-2fa-verify}.ts · auth.post.admin-logout-all.ts · lib/config/environment.ts · lib/stacks/api-stack.ts. Code (frontend): admin-web/app/shared/{lib/query-client.ts,ui/query-error-state.tsx} + the seven list routes · web/app/shared/lib/query-client.ts · web/app/routes/portfolio.tsx. No schema or contract change. Source: QA report, 2026-08-05.

📅 August 6, 2026 — 07-redemption drops its archaeology, and three "not implemented" callouts turn out to be fixed (v3-116)

🚪 The redemption page keeps its mechanics; what leaves is its own history

07-redemption went 608 → 429 lines (v3-116). This page is the redemption owner, so the cut was a different shape from 04 and 05: nothing here was restating another page's material. What it was carrying was its own archaeology — deploy addresses, commit hashes, function selectors, "an earlier draft of this page said…", "27 tests passed" — plus eleven facts each repeated 3 to 9 times, and 40 call-outs of which most were status badges wearing warning colours. Every mechanic stays: the model selector, exit gates, the 3-state lockup and four penalty types, both flow sequences, Model B's window arithmetic, carry-first fill, ladder generations, cancellation, 3-layer partner funding, the anomaly-hold table. Call-outs are down to 11, 8 of which are the numbered flow steps, and warning / danger now means investor-funds risk and nothing else.

The clone caveat is stated once instead of seven times. "Pools are Clones with the implementation fixed at creation, so this is new pools only" was pasted beside every shipped contract item. It now sits once under the page title and applies page-wide — kept rather than dropped, because it is still the reason the epoch FE has no pool to build against.

🔴 Three full-width danger / warning callouts said Model B was designed and never switched on. All three were closed on 2026-08-03 by 6c0a08f, and the page had not caught up. The confirm action now reaches the chain (pools.post.epoch-schedule.tssetEpochFundingDateOnChain, at POST /pools/{id}/epoch-schedule); the two provenance columns exist (migration 0111) and DETAIL_SELECT returns them along with the rest of the schedule, so the 확정 badge and the countdown have their inputs; and setEpochSchedule is wired into create — the terms are required, asserted back off the chain after deploy, and the contract reverts ScheduleNotConfigured rather than falling through to the lazy engine, so no new pool can deploy unanchored. Indexer writers and ABI fragments for the three schedule events are in place too. That also closes the read-path finding recorded in v3-107.

🔴 The wind-down numerator on this page had a subtraction the contract does not do. 07 said distributable = reserve + recalled funds − settled-but-unclaimed debt; PlatformPool.sol:1370 is reserveBalance + heldFundReleases + totalEpochTopUp. The − redemptionCommitted term shipped in v3-100 and was removed in 0abe168 — the three buckets are already net of it, so subtracting again double-counted. The remaining asymmetry is in the denominator (R10), which is what the page now says.

And one contradiction inside the page: the NAV-decrease pricing carve-out was "✅ Shipped" in one section and "🔴 not implemented" in a flow step three screens later. Shipped is correct; the flow step links to the single statement now.

Two suspected fabrications were checked and are real. GET /redemption-requests/{id}/exit-gate is a live ADMIN-only endpoint, and the page's claim that POST …/fm-accept was dropped is accurate (migration 0050). Unlike the 04 and 05 passes, this page had invented nothing.

The same deploy status was propagated to the four pages the earlier sweep named but did not reach08a (the previewEpochClaim / ClaimBeforeCancel callout was still titled "not yet deployed" over a body that said shipped, and setEpochSchedule still read "has no caller — so no pool is anchored"), 09a-custody and 26-glossary (R10 "needs a redeploy" / "as of the pending redeploy"). All four now read deployed 2026-08-04 (commit 0abe168, new factory), new pools only — a pool created before that deploy is an old-implementation clone and keeps the old behaviour. 08-smart-contracts needed no change; its contract table and executeWindDown pseudocode were already correct. Genuine "not built" markers were left alone: DPD auto-writedown, the stalled-request notification blocked on copy, config immutability's general redeploy note, and every legacy-pool caveat.

Docs: 07-redemption · 08a · 09a-custody · 26-glossary · 14-decisions v3-116. Verified: build green, check-doc-anchors 2 broken (both allowlisted) / 0 unexpected; every inbound anchor into 07 still resolves. No schema, contract or product change. Source: JY (product), 2026-08-06.

📅 August 6, 2026 — 05-investment-lifecycle goes back to being a walkthrough, and a fabricated column turns up (v3-115)

🧭 The investor's journey, with the mechanics linked instead of restated

05-investment-lifecycle went 298 → 209 lines with no fact removed from the docs (v3-115). Unlike 04, 05's 29 step call-outs were never the problem — a walkthrough is supposed to look like that. What it was carrying was a second copy of mechanics another page owns: the LP-quantity formula and the reserve split (23-money-path §3), the fee arithmetic and both fee destinations (04 → How Yield Works), the 3-state lockup and all four penalty types (07), and the reconciler's migration number and scheduler wiring (11). Two copies of a formula is how they drift apart. Each step now says what happens to the investor and links the mechanism; the journey narrative, the Reinvest V1 Policy and Yield Settlement on LP Transfer stay in place, since 05 owns those.

One item had no owner and was moved before being cut: the legacy one-argument reinvest(uint256) exposed by pools deployed before the two-argument signature landed. It now sits on the reinvest row in 08a, next to the same kind of caveat on cancelRedemption.

🔴 portfolio_positions.nav_at_investment does not exist. Phase 1 had a whole step for it — "records the NAV at investment time for accurate P&L tracking". grep returns 0 hits repo-wide and the column is absent from the schema. What a deposit actually writes is effective_value = tokens × nav_per_token, a current valuation the next NAV move overwrites, so no entry NAV is retained on the deposit path at all. The real entry_price column is written only by the indexer's LP-transfer writer when it first creates an unseen position. Named in place rather than deleted, because a cost-basis P&L feature could have been specified against it. Open and routed: should a deposit keep its entry NAV? Without one, per-position P&L is not derivable.

Two more stale claims fixed while trimming. Phase 3 still had "Admin reviews redemption queue… checks reserveBalance… calls approveRedemption" as the gate — v3-82 moved that check inside requestRedemption and left approveRedemption as an optional manual settle; 07 has been right since, and 05 was the straggler. And the fee step called treasuryWallet "the immutable timelocked destination (changed only via proposeTreasuryChange + 7-day timelock)" — proposeTreasuryChange returns 0 hits, the real setTreasuryWallet is an instant admin write by deliberate design, and the wallet is per-pool storage rather than one global; 04 → Fee Destination had it right.

The partner-webhook correction, which had been living in a walkthrough step while 04 and v3-02 both pointed at it, now has a card of its own — v3-115 is the single home and the three pages link there.

Docs: 05-investment-lifecycle · 08a · 11-db-schema (portfolio_positions gains a clean anchor) · 14-decisions v3-115. Verified: build green, check-doc-anchors back to 2 broken (both allowlisted) / 0 unexpected. No schema, contract or product change. Source: JY (product), 2026-08-06.

📅 August 5, 2026 — 04-pool-models drops its archaeology, and a second fabricated partner API turns up (v3-114)

🧹 A concept spec in the present tense, and three stale claims it exposed

04-pool-models went 1365 → 1134 lines without removing a fact from the docs (v3-114). Everything cut was first verified present in its owner — the "why this changed" narratives to their v3-NN cards, migration numbers and ship dates to 11-db-schema and this changelog, NAV and FX to 06, epoch and redemption mechanics to 07, contract internals to 08 / 08a — and the single item that lived only in 04 was moved into v3-106 before being cut, namely how POST /tranche-writedown applies NAV. Within the page, five duplicated blocks collapsed to one home each, and the two near-identical mutability tables (93 lines asking the same question twice) became one table that also picked up epoch_duration_days and the NAV-safety bounds, which were in neither despite the heading claiming all 24 dimensions. Both old anchors are kept, since #editability-after-active alone had six inbound links from other pages.

Three stale claims fell out of the pass, each checked against code. is_hidden was documented as hiding a pool from the investor list and 404-ing its PDP; pools.get.list filters it inside the operator branch only and isPubliclyVisible never reads it, so investors see a hidden pool normally — 10-status-machines had this right already and 04 was the straggler. v3-02 still ended "Partners are notified via API/webhook", the same invention removed from 05 earlier today. And EXTERNAL_PARTNER, named in 04 as a pool kind, appears nowhere in the database, lambdas or contracts.

🔴 The find worth acting on: 08-smart-contracts → Partner Notification published a full partner API that does not exist. POST {partner_endpoint}/aset-governance-action with an example payload including can_object_until — implying partners hold an objection right during the 7-day governance timelock. grep returns 0 hits for both identifiers, and pools.post.governance.ts sends no notification at all, to anyone, by any channel. The on-chain events are real, so both pages now say what is true: the partner watches the event. Every fabricated element is named in place rather than deleted, because a partner could have built against that payload. Open and routed: should a governance notification exist? A timelock is only a review window if someone is told it started.

Docs: 04-pool-models · 08 → Partner Notification · 14-decisions v3-114. No schema, contract or product change. Source: JY (product), 2026-08-05.

📅 August 5, 2026 — The sidebar gets one organizing axis: ten groups by kind of document (v3-113)

🗂️ Grouped by what a document *is*, not by which feature it sits near

The backend sidebar is regrouped on a single axis — orientation → identity/compliance → money concepts → contracts → backend runtime → reference → operations → frontend → meta — into ten groups: Getting Started · Identity & Compliance · Protocol & Money · Contracts · Platform / Backend · Reference · Operations · Frontend · Decisions & Roadmap · Team (v3-113). Three are new. Contracts takes 08 + 08a, because contract implementation and code reference are a different kind of document from the concepts they implement; Identity & Compliance takes 03 + 21 out of Getting Started, which had been absorbing anything unplaced; Reference collects the consult-don't-read pages 11 · 15 · 24 and gains 20-joob-pool-config, which is one fund's configuration rather than general protocol. 09a-custody moves the other way, from Platform into Protocol & Money, since non-custody and money-path immutability are money concepts. 26-glossary stays in Getting Started on purpose — an orientation tool, not a lookup appendix. The change is navigation only: it lives entirely in .vitepress/config.ts, content and filenames and anchors have a zero-line diff, and check-doc-anchors came back at its 1187 / 2 broken (both allowlisted) / 0 unexpected baseline. ko now mirrors the same ten groups in the same order and gained the four entries it had been omitting (26 · 08a · 22 · 25) as new stubs, with every existing ko file — full mirrors included — left untouched.

Docs: docs/vitepress/.vitepress/config.ts · 14-decisions v3-113. No content, schema, contract or product change. Source: JY (product), 2026-08-05.

📅 August 5, 2026 — Money path goes back to being a map; implementation depth moves to its owners (docs only)

🗺️ Restructure to the page's own charter — no new decision

23-money-path's header has always promised one thing: where money moves, plus the wallet/bank naming SoT and the on-chain ↔ off-platform boundary. The deposit gap-fill earlier in the week pushed §3 to code-audit depth (192 lines of wei arithmetic, writer races and invariant-test internals), which is a different document. §3 is now 125 lines and the page is eight flat sections again.

Moved, not deleted. Nothing was dropped on the floor; each cut landed somewhere that owns it:

  • Reserve-split rounding (floor direction, raw-first rationale, why the two dust floors differ) → 08a → Funds-integrity guards. It was not in 08's Amount Units table, so it needed a home before the link could exist.
  • Two-writer recording internals (provenance divergence, idempotency, and the effective_value valuation race between POST /deposits and the indexer) → 11-db-schema → deposits, which previously had only a one-line reconciler note.
  • Wei/decimal formulas → already owned by 08 → Amount Units, so link only.
  • Wind-down pricing (the settledUnclaimedLp denominator, why redemptionCommitted is no longer subtracted) → already owned by 06 → R10. §7 keeps only the map answer: which buckets fund the terminal payout, and that the released partner remainder is not recoverable on-chain.

One home per fact. Reserve was defined in both §6 and §6a; the two are merged and §1 now points at §6. Fee destinations are defined in §4 only. The fiat leg's unrecorded-ness is stated in §2 and the §3e hop table, and §5's duplicate sentence became a pointer.

Four stale items fixed while restructuring. fund_fee_wallet was still described as "not yet in the DB or contract" — it shipped in migration 0067 and as on-chain fundFeeWallet, and the same page's §4 already said so. The fee taxonomy still carried a "current vs go-forward" column pair naming admin_fee_pct / perf_fee_pct, which migration 0075 replaced with bps keys; it is now one column with per-leg status, and performance fee is marked ⏸️ dormant because no admin surface sets the rate (fees.ts computes 0). The worked example had been deducting a $200 perf fee that cannot occur, so its net was wrong. And an orphaned "4." numbering in §4 (with no 1–3) became a plain paragraph.

ko/23-money-path was worse than the EN. It is a full 178-line mirror rather than a stub, and it had drifted to three deleted-function references: releaseToPartner() in both the diagram and the steps, and distributeYield + withdrawFees as the current fee mechanic. It also named the wrong reserve mirror column and still claimed early-exit penalties increase reserveBalance, which v3-85 reversed to fund_wallet. All corrected, plus the v3-112 partner-remainder rule.

Blocked and deliberately untouched: every hold-back sentence (§6's dormant-lever block, the caveats in §3a / §3f / §3g, the §8 immutability row) is waiting on the hold-back review, per the restructure spec's dependency list.

Swept alongside: "the 90%" per v3-112. 44 prose sites across 02 · 04 · 05 · 07 · 08 · 09a · 11 · 20 · 24 · 26 and the ko mirrors of 09a / 24 / 23 now say partner remainder. 90% is the figure at the default reserve_bps = 1000 only — under the reserve-zero launch assumption the remainder is 100%, and at 2000 bps it is 80%, so writing 90% turned a per-pool config field into a constant. Two doc-quoted investor banner strings were reworded too (verified absent from apps/web / apps/admin-web, so no FE drift); the 15 occurrences inside 14-decisions and this changelog are dated history and were left alone.

🔴 And the fake webhook was worse in 05 than in 04. 05-investment-lifecycle Phase 1 step ⑦ did not merely mention a partner webhook — it published a full API contract for one: a POST {partner_endpoint}/aset-lp-mint request body with eight named fields, a five-attempt retry schedule (1s/5s/30s/5m/1h, exponential backoff), and a GET /lp-mints?pool_id=X&since=… pull endpoint for missed events. grep across apps/infra, apps/web and apps/admin-web returns 0 hits for all three identifiers. A partner could have built against that page. The step now describes what actually records a deposit (the two writers) and carries a 🔴 naming every fabricated element, so the invention is on the record rather than quietly deleted.

Source: JY, 2026-08-05. No schema, contract or decision change.

📅 August 5, 2026 — The hold-back is built, shipped and never pulled: naming that, and dropping "the 90%" (v3-112)

🎚️ A word for built-but-unpulled, and one for what the partner actually gets

A code audit of the hold-back / heldFundReleases mechanism, run to decide whether it had quietly been dropped. It has not been dropped, and nothing is broken — the answer is a third state the docs had no word for, so this entry is naming and consistency work (v3-112). No shipped behaviour changes.

What the audit found. The contract side is complete and fuzz-tested: storage, setFundingRestricted, _releaseHeldFunds with its clamp, both fill paths, events, getters, all in the deployed ABI. The product side has the consumer half built and idle — the indexer watches FundingRestrictedSet, business/holdback-release.ts decides the clamp notice, holdback_release_deferred is ⏸️ ON HOLD in the catalog — and no producer at all: setFundingRestrictedOnChain has zero callers, s.fundingRestricted is written in exactly one place with no initializer, so it is false on every pool, both fill paths are unreachable, and heldFundReleases is 0 everywhere. §6a was already honest about this in a danger callout; the surrounding pages were not.

D1 — "dormant lever." The switch exists on-chain, is tested, works, and nothing pulls it. Not removed, not broken, not automatic. Now defined once in 26-glossary → Status labels and applied wherever the hold-back appears, so a reader planning against §3g's failure table can see that one row of it cannot happen.

D2 — "partner remainder", not "the 90%." The split is amount × reserveBps / 10000 to reserve, the rest to the partner. 90% is only the default-reserve_bps figure; under the reserve-zero launch assumption the remainder is 100%. Nine spots in 23-money-path plus the glossary, notifications and v3-100 now say remainder. ⚠️ Other pages still quote 90% (04-pool-models has the bulk) and are not swept here.

🔴 One new question, routed not closed. _releaseHeldFunds computes free balance against three obligations, but the Foundry backing invariant counts fiveunclaimedYield is debited at distribution, so SUM(pendingYield) (yield promised to holders, physically in the pool, awaiting claim) is never subtracted, and freeNorm is overstated by exactly that. Same defect class as the §9-3-b bug the clamp was written to fix, one obligation over. Latent, since the lever is dormant, and code-reading only — the fuzz campaign was not re-run. Must be settled before any hold-back product work starts. → contract owner.

Also corrected: 26-glossary had unclaimedYield as "distributed yield nobody has claimed". It is the reverse — deposited, not yet distributed (YieldLib.sol:122 credits, :179 debits). That misreading is precisely how the fifth obligation went missing, so the glossary now flags the name as misleading and names pendingYield as the separate obligation.

Docs: 23-money-path §6 · 26-glossary · 22-notifications · 14-decisions v3-112. Source: JY (product), 2026-08-05.

📅 August 5, 2026 — Reinvest prices at the announced NAV: the open question from this morning is a decision (v3-111)

🔄 One announced-NAV rule, three paths

The money-path deposit audit earlier today (entry below) found that reinvest prices at s.navPerToken while deposit and redemption read effectiveNav, and logged it as an open question rather than a bug — the defensible reading being that reinvested yield is already inside the pool. That question is now closed as a product decision: reinvest prices at effectiveNav, exactly like a deposit (v3-111).

What settled it is that the yield being converted sits in accruedYield, which is a claim on the pool, not a priced LP position — a NAV change does not mark it down, so nothing was absorbed on the investor's behalf. Pricing the conversion at the pre-announcement NAV therefore hands the reinvestor fewer LP than the announced book value supports, charging them for the same write-down twice: once on the tokens they already hold, once in the rate on the new ones. R2·R3 exists to remove a free option in the investor's favour; here the mispricing ran the other way, so the two paths were never symmetric arguments. Both point at effectiveNav.

A reinvest and a fresh deposit of the same size in the same block now mint the same number of LP tokens. No new parameter, no config, no governance surface, no DB column; the reinvest gates (allowRollover, minReinvestAmount, same-pool only per v3-64) and the E1 money-path parity are untouched — this is the price of the conversion and nothing else.

🔴 Code fix pending — CH QA card. YieldLib.reinvest must read PoolCommonLib.effectiveNav(s) in place of s.navPerToken, matching PlatformPool.sol:753. One line in one expression, but a contract change: poolImplementation is immutable, so it ships as a new implementation + factory and reaches newly created pools only. Per the current/target discipline the docs now state the target rule as canonical, with a single 🔴 callout naming the present behaviour rather than double-narrating every sentence.

Docs: 05-investment-lifecycle → Reinvest V1 Policy · 06-writedown-nav → R2·R3 (the table now lists all three paths with per-path status) · 14-decisions v3-111. Source: JY (product), 2026-08-05.

📅 August 5, 2026 — Protocol-section sweep: the 08-04 deploy and the wind-down numerator fix were only half propagated (docs only)

🧭 Drift audit — no new decision

An external review flagged four 🔴 items on the Protocol pages. Two were already fixed in the branch and are not reproducible (15-api-reference has said settleYield since v3-102 and documents include_due; 04-pool-models Fee Destination has the shipped 4-arg settleYield form and no "CH handoff"), which is itself the finding: the review was reading a build that predates those commits, so an audit against the published site can report already-closed items as open. The other two were real, and chasing them surfaced a larger, same-shaped problem underneath.

The 2026-08-04 deploy and the wind-down fix reached 06 / 11 / 14 and stopped. Commit 0abe168 shipped previewEpochClaim, settledUnclaimedLp and both halves of the wind-down correction, and those pages say so — but the sibling pages still described the same behaviour as pending, sometimes in the same table as a ✅ shipped row. Every instance below is now consistent, with the standing ⚠️ new pools only caveat (Clones fix the implementation at creation, so a pre-0abe168 pool still runs the old code):

  • previewEpochClaim "not callable on a deployed pool yet / needs the redeploy"07-redemption §epoch, and "blocked until the deploy" in 08a. The FE is blocked on a pool created from the new factory, not on a deploy and not on writing anything.
  • R10 wind-down denominator "needs a redeploy" / "🔴 target"04-pool-models, 08a, 23-money-path §7, and "denominator correction pending" in the 08-smart-contracts contract table. It is deployed.
  • The numerator was stale in the opposite direction. Six pages still printed distributable = reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted. That subtraction was removed in 0abe168: _reserveFilledGross already debits the three liquidity buckets at settlement and parks the gross in the disjoint redemptionCommitted, so charging it again removed the same dollars twice — fund $150, settle $100, and the expression returned max(0, 50 − 100) = 0, pricing a pool holding $50 at zero. Only 06 and 14 recorded this. The two errors compounded rather than cancelling, one shrinking the numerator while the other inflated the denominator, which is why both sides moved in one change. v3-100 Correction 2 now carries a supersede note.
  • v3-84 / v3-85 "contract change pending redeploy" in 08-smart-contracts — live in the deployed RedemptionLib: payout = grossAmount − request.penaltyAmount (RedemptionLib.sol:524), penalty to s.fundWallet, isYieldPenalty always false.

Deleted functions named as current mechanics. releaseToPartner() does not exist — it is the event ReleasedToPartner and an inlined safeTransfer — yet 04 / 08 / 08a instructed calling it in eight places, including a numbered step and three diagrams. 04-pool-models also had the removed withdrawFees as the single fee call; it is settleYield(stablecoin, netAmount, treasuryAmount, poolMgmtAmount), with distributeYield + withdrawFees gone from the contract. All corrected.

Two items routed rather than closed here:

  • pools.reserve_balance, not pool_chain_deployments.reserve_balance23-money-path §1 and §6 named the wrong column. The indexer writes pools.reserve_balance (migration 0088) on every run; the pool_chain_deployments column exists and has never been written. → NAV owner: this answers the open "does the fallback ever fire" question — if nothing populates the primary, the fallback fires always.
  • The partner LP-mint webhook does not exist04-pool-models deposit steps 8–9 described POST {partner_endpoint}/aset-lp-mint and "Partner updates their own records". grep for aset-lp-mint / partner_endpoint across apps/infra returns nothing. Marked 🔴 unimplemented in place; build-or-drop is a product call.

Also swept: container fences that were rendering as literal ::: on the published page. Six spots fixed across 07-redemption, 08-smart-contracts, 21-holder-verification (orphaned / duplicate / indented closing fences, one :::::: typo that had merged a close and an open) and 05-investment-lifecycle, where ::: partner was never a registered container type — it is now fund-only, which is registered and already carries the same orange the step's badge uses.

⚠️ Four remaining literal ::: are a different defect and were left alone: nested containers do not render in VitePress, and 02-core-concepts (warning inside info), 06-writedown-nav (danger inside tip, twice) and 14-decisions (tip inside decided, v3-106) each nest one. Fixing them means restructuring the enclosing block, which is a content decision rather than a fence repair.

Source: external review + JY verification against apps/contract/src and 0abe168, 2026-08-05. No schema, contract or decision change.

📅 August 5, 2026 — Deposit gets the money-path depth redemption already had, and two gaps fall out of it (docs only)

🧾 Consolidation and audit — no new decision

23-money-path § Stream ① was the shallowest of the three streams: a four-step happy path and one example, against a redemption stream that covered exhaustion, the hold-back clamp, deferred release and the backing invariant. It now covers the on-chain leg step by step, LP quantity and price (§3b), reserve rounding and dust (§3c), where reserve_bps comes from and when it is read (§3d), the fiat leg hop by hop (§3e), recording provenance across the two writers (§3f), failure and in-flight states (§3g), and a numeric worked example that carries the fiat leg (§3h). Everything is written from PlatformPool.deposit, YieldLib.reinvest, GovernanceLib, the indexer writers and process_deposit_atomic; what the code does not answer is listed as an open item rather than described.

Three corrections of record. releaseToPartner() is not a functionReleasedToPartner is an event and the transfer is an inlined safeTransfer at the tail of deposit(); 04 / 08 / 08a rendered it as a call and were corrected the same day. LP is priced at effectiveNav, not navPerToken, which is how R2·R3 actually reaches the deposit path. And fund_bank_account / fund_fee_bank_account are names, not columns — no such field exists on pools or funds.

Two gaps the audit surfaced, both recorded in place and neither yet decided:

  • 🔴 reinvest prices at s.navPerToken, not effectiveNav. A reinvest placed during a queued NAV decrease mints at the stale, higher NAV and receives fewer LP than a fresh deposit of the same size in the same block. Intended or an R2·R3 oversight was left as an open question. → ✅ Closed the same day as a product decision: reinvest prices at effectiveNav like a deposit (v3-111); code fix pending.
  • 🔴 The release leg has no off-chain record. ReleasedToPartner and FundReleasesHeld are absent from the indexer's watched events, so the 10/90 split is in no table; Reinvested is likewise unwatched, so reinvest has no reconciler where deposit has one within ~2 minutes.

Beyond the contract boundary the honest finding is that the fiat leg is unrecorded in both directions and there is no per-transfer confirmation of it. The asymmetry that makes redemption read deeper is instrumentation, not design: a shortfall there is contract state, a scheduler chases it, and the return leg has a record endpoint. Deposit's release has none of that, and nothing reads fund_wallet's balance, so an unconverted balance is indistinguishable from a converted one from every Aset surface. Who reconciles released USDC against fiat received, on what cadence, and against which artefact, is left as an open item.

Also noted and not changed, being outside this section: §1 and §6 name pool_chain_deployments.reserve_balance as the DB mirror, but the column the indexer writes is pools.reserve_balance (migration 0088; the pool_chain_deployments column exists and was never written).

Source: JY, 2026-08-05. No schema, contract or decision change.

📅 August 4, 2026 — The NAV/epoch contract work is deployed to dev, and it reaches new pools only (v3-105 · v3-107 · v3-109)

🚀 A new implementation means a new factory, so existing clones keep the old code

The contract half of three decisions went out together: new factory 0xE1E2E9743fd3d00F6f50eBFCb765A91411D6DA90 → new pool implementation 0x27D9948FD2A3aF7b035e99d23440A3A877aef968 (21,186 B, up from 19,802 B), built from commit 0abe168. Deployed to Base Sepolia (dev) on 2026-08-04.

What that deploy contains, verified by selector against the live implementation rather than taken on trust: previewEpochClaim (9ccd0389), setEpochSchedule(uint256,uint256,uint256), setEpochFundingDate, setEpochSettleAfter, epochFundingDate, and settledUnclaimedLp (915d0aa1). That last getter is the useful one — it was introduced by 0abe168, so its presence pins the deploy to that commit, and 0abe168 also carries R2·R3 (PoolCommonLib.effectiveNav, so deposits and redemption requests price at the announced NAV during a decrease timelock) and R10 (the wind-down denominator). The two epoch commits (6c0a08f, 3feeb87) are ancestors of it, so the schedule work is included by construction.

⚠️ The part that matters operationally: this does not upgrade any existing pool. poolImplementation is immutable on the factory, so shipping a new implementation means shipping a new factory, and pools already created are Clones bound to whatever implementation they were cloned from. settledUnclaimedLp() reverts on the newest pre-existing pool (created 2026-07-23), and previewEpochClaim is absent there. As of this entry no pool has been created from the new factory, so none of the new behaviour is observable on any pool a front end can point at yet — an FE integrating previewEpochClaim needs a pool created after the deploy.

A second consequence is permanent rather than temporary: setEpochSchedule is create-only, so a pool created before the deploy can never be anchored to the Model B schedule. Those pools keep the legacy lazy clock with no request window, and moving them means recreating them.

Not verified from here: whether the backend's FACTORY_ADDRESS_<chainId> now points at the new factory. That value lives in the deployed Lambda environment, not in the repo, and if it still names the old factory then newly created pools would continue to clone the old implementation. Worth confirming before treating the deploy as effective.

Docs updated: 06-writedown-nav (R2·R3, R10) · 07-redemption · 08a-contract-reference · 10-status-machines · 11-db-schema · 14-decisions v3-105 / v3-107 / v3-109 status blocks. Source: CH (deploy), JY (verification), 2026-08-04.

📅 August 4, 2026 — `ARCHIVED` was never a lifecycle status and `CLOSED` has no way in; archive/delete/hide split into three (v3-110, docs only)

🗄️ A documented state nothing can write, and an enum value that does not exist

Two symmetric defects in how a pool ends. ARCHIVED is not a lifecycle_status value — it is a label the admin mapper derives from deleted_at, shadowing whatever the real lifecycle is — yet four documents listed it as a lifecycle state with transitions into it. CLOSED is the opposite: a genuine enum value, documented per-state, with no write path anywhere in the codebase. Screens, badges and copy were built for a state that cannot occur.

A — archive narrows; is_hidden takes over what archive was being misused for. Archive now means retire a finished pool: a terminal-ish lifecycle with every position at zero, mirroring an on-chain pause(), restore requiring a reason and an audit trail. Today's guard checks open positions only when the pool is ACTIVE, so a CLOSED / MATURED / IMPAIRED / WIND_DOWN pool with live investor positions can be archived right now — it disappears from the surface while people still hold money in it. That looseness existed for a reason: operators needed to pull a live pool off the list and archive was the only lever. So narrowing it requires the missing control — is_hidden, a free toggle that changes visibility and nothing else (no capability, no on-chain effect, no lifecycle effect). A force flag on one shared endpoint was rejected: the two acts differ in reversibility, on-chain effect, and whether an investor is affected. Naming is fixed at Archive (reversible) vs Delete (permanent, DRAFT / DEPLOY_FAILED only), and audit gains POOL_ARCHIVE / POOL_RESTORE — at present hard delete and archive share POOL_DELETE (separated only by metadata.mode) and restore is logged only as a generic POOL_UPDATE, being a bare PATCH of deleted_at: null with no reason and no dedicated event type.

B — CLOSED gets POST /pools/{id}/close plus auto-close at end_date. It means fund-raising is over: deposits blocked, redemptions and yield claims untouched, and reversible (reopen) while the pool is healthy and end_date has not passed — closing a subscription early is operational, which is what separates it from MATURED and WIND_DOWN. end_date already exists; B gives it a second job as the automatic trigger so a pool does not sit ACTIVE past its own close date.

C — 전손 / total loss, one term each, paired with escalate_flag on first use. Not cosmetic: the same event triggers the mandatory escalation, and a decision nobody can name consistently cannot be enforced consistently. Q2 reaffirms that a total loss escalates through the existing on-chain 7-day proposeImpairmentexecuteImpairment timelock — the same mechanism as v3-109 D3/T2, recorded from the closure side so the policy reads on its own; not a second implementation. D — one status chip. pool-badges.tsx calls itself "Single effective pool-status chip" and then renders a second Paused chip; since v3-78 auto-clear already guarantees one live flag at a time, the second chip is a second answer to a question with one answer.

It is implemented, apart from one text change that lives in Notion. Migrations 0123 (pools.is_hidden) and 0124 (the 전손 comment); everything else needed no schema change.

A — archive narrows, and it now closes the contract too. The position check ran only for ACTIVE, so a CLOSED / MATURED / IMPAIRED / WIND_DOWN pool could be archived with holders still in it — the states a pool passes through on its way out, i.e. exactly where money is most likely still owed, and archiving hides the pool from the admin list with the holders inside it. The check now covers every lifecycle but DRAFT. Archiving also sends pause(): it used to write deleted_at alone, leaving a pool invisible in the console and still accepting deposit() from anyone holding the address. Restore requires a reason, logs POOL_RESTORE, refuses WIND_DOWN (terminal on-chain — it would come back unable to operate), and deliberately does not unpause, because v3-78 says de-escalation never auto-restores; the pool returns paused and reopening it is a separate act.

is_hidden shipped narrower than this card specifies. The text says "off the investor list and PDP"; it is admin-list only. A holder has to reach the pool detail to redeem, claim yield and read a write-down notice, so hiding it there puts someone else's money behind a decision made for the operator's convenience. If a pool must leave the investor's view that is CLOSED or archive, not a display flag.

B — CLOSED finally has a way in. It was read across the product (the redemption handler accepts it, the yield scheduler excludes it, the investor banner has copy for it) and written by nothing: an unreachable state, so "stop taking new money, let existing investors run to maturity" had no implementation. POST /pools/{id}/close plus an end_date pass in the lifecycle scheduler. ⚠️ That pass runs after the maturity pass, so a FIXED_TERM pool past both leaves as MATURED and is never demoted to CLOSED — demoting it would reinstate early-exit penalties on holders who had earned their way out. And the DAG in 10-status-machines said any state → CLOSED, which is wrong in the same direction: only a pool still taking subscriptions has a subscription to close.

D — one chip, and the auto-clear rule grows a second half. Paused now replaces the lifecycle chip rather than sitting beside it (pause is only permitted on ACTIVE, so the second chip carried nothing). The MATURED / CLOSED transitions clear is_paused — 🔴 including the on-chain unpause(), which the decision did not mention. v3-92 is explicit that a DB-only clear leaves paused() == true with deposits reverting invisibly and no UI route back; that stranded Test Pool 260616-base. The scheduler runs unattended, so a DB-only clear there would strand pools nobody is watching.

G2 took option ①, not ②. A pause taken while a pool had no contract (publish → DEPLOYING, or DEPLOY_FAILED → Retry) is now carried onto the new contract by the deploy worker. Refusing the pause instead 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 — clearing it to match the chain would silently undo the operator's decision.

G4's blast radius was checked rather than assumed. Removing OPERATOR from pause is a permission narrowing, so: zero OPERATOR pause events in the audit log. Nobody was using it; the handler was simply disagreeing with both 09-rbac and the admin UI.

Two more things the DB settled. The MATURED + PAUSED ghost combination has exactly one instance, and it is already archived — which under A is the correct state, not a ghost, and its contract agrees (paused() == true). And there are no archived DRAFT rows, so G6's stranding side effect has nothing to clean up.

The archive gates moved into a pure lib/shared/business/pool-archive.ts and got 16 tests, because every rule here is a refusal and refusals fail open — drop a status from the allowlist and nothing throws, nothing logs, the archive just succeeds. The full lifecycle enum is enumerated so a value added later fails a test rather than silently skipping the guard. Four mutations confirm it bites: reverting the guard to ACTIVE-only fails 7, letting the query param beat DRAFT fails 1, dropping WIND_DOWN fails 2, and the single-column S-18 comparison fails 3.

Still open: the Notion PRD wording (심각손실 → 전손), which is outside the codebase. Verified: infra 326/326, copy / notification-producer / on-chain-unit guards clean, web and admin-web typecheck, admin eslint unchanged from baseline. Decisions: v3-110. Source: JY (decisions) + CH (implementation), 2026-08-04.

📅 August 4, 2026 — The reserve is not a loss layer, and the NAV denominator was letting new deposits dilute a recorded loss (v3-109, docs only)

📉 A $100k loss against a $100k reserve was recorded as no loss at all

The reserve is reserve_bps of investor deposits, held in the Pool contract. That makes it investor money already inside the claim the NAV denominator prices — so subtracting it from a loss as well credited investors twice for their own capital, and the pool reported par while its assets had genuinely fallen. R8 removes the reserve from loss absorption entirely. The order is now collateral → buffer → Junior → Mezzanine → Senior → NAV, and the reserve keeps its two real jobs: paying redemptions, and setting the wind-down floor. This retires the first-loss framing in v3-16 and the premise of BD4; the 10% sizing survives as a liquidity target.

Removing that layer would have left standalone pools with nothing between a loss and NAV — reserve is 0 at launch by design — which is why R6 promotes the manager equity buffer from a disclosure sentence (equity_buffer_rule, free text) to a computable parameter: bufferCap = total_deposited × buffer_rate_bps / 10000, a buffer_direction (who takes the loss) and a buffer_basis flag where NET means the partner's reported loss already reflects the absorption and the cap is forced to 0 — because subtracting it twice would repeat the exact double-count R8 just removed. 🔴 Per-pool rates and the direction of the commitment (first N% vs. excess above N%) await contract confirmation with Joob; no rate is agreed.

R9 — the denominator was total_deposited, and that is a drift, not a rounding artifact. total_deposited only grows; totalSupply shrinks when LP is burned. 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. The denominator is now totalSupply, the count of claims that actually exist. Note this reverses the 2026-07-27 lock recorded in A4.

R10v3-100 fixed the wind-down numerator and left the denominator behind. distributable subtracts redemptionCommitted, but totalSupply still counts the escrowed LP backing exactly those payouts — USD out, tokens in, so remaining holders are underpaid by that slice. Target distributable / (totalSupply − settledUnclaimedLp), a new counter incremented at settlement and decremented at claim. ⚠️ Scope: settled-unclaimed LP only. Pending and rolled-over LP stays in the denominator — its USD is in the numerator too, so it is already symmetric, and excluding it would break the pro-rata participation the epoch design guarantees rolled-over requesters. Requires a contract redeploy, so the docs describe current behaviour with the target named rather than rewording to a pool that does not exist yet.

Also settled: R7 — FX is not an input to NAV (performance in the operating currency; conversion at redemption), which is why an FX move never queues a timelock. R2·R3 — a pending decrease is public for 24h, so deposits and requests made inside that window price at the announced NAV; the old text said investments "stay open" at the current price, the 2026-07-02 verbal decision said trading was blocked, and neither was implemented or correct. R5·R5.5 — Aset mirrors the fund's write-off and authors no provisioning curve; delinquency alone never moves NAV. D1 — the manual NAV paths become ADMIN/SUPER_ADMIN, since leaving the hand-typed route open to OPERATOR made the weaker control the effective one. D2 — proposals get a recorded dismiss reason and a 48h TTL. D3/T2 — a total loss must escalate to on-chain IMPAIRED via proposeImpairment7-day timelock → execute, which replaces the two-person approval previously floated (seven public days beat two simultaneous private signatures).

It is implemented, and building it found six live defects the decisions had not predicted. Ranked by what they were doing to money:

🔴 A healthy pool was one approval away from being priced at zero. R7 was logged as a display task. It is not: 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 returns −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 fix is 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.

🔴 The indexer had been dead for 13 of the last 45 days. Alchemy caps eth_getLogs at 10 blocks and the engine asked for more, so every run threw and no event was mirrored. This is the 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. Fixed with an adaptive range that shrinks on the provider's error, and the reserve/LP mirror moved outside the event pipeline so a log failure can no longer take the mirror down with it.

🔴 The reserve mirror was off by 1e12. reserveBalance() is an 18-decimal counter and the indexer divided by 1e6: a pool holding $2.10 was recorded as $2,100,000,000,000. Nobody saw it because the indexer was also dead. Branded RawAmount / NormalizedAmount / NavPrice types plus a build guard (check-onchain-units.mjs) now make the axis part of the type rather than a comment.

🔴 Wind-down charged the same exit to remaining holders twice. R10 was recorded as one asymmetry in the denominator; the numerator had the mirror-image bug. _reserveFilledGross already moves settled gross into redemptionCommitted, so executeWindDown subtracting it again returned max(0, 50 − 100) = 0 for a pool holding $50. Fixed on both sides in the same change.

And the test count was lying. tsc does not delete output for a source file that no longer exists, so 18 tests from deleted files kept running out of dist — 284 reported against 266 real. Reproduced by planting a ghost file; fixed with rm -rf dist ahead of build and test.

R6 stopped waiting for an answer and became configuration. 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 a column does not guess: 0118 puts buffer_rate_bps, buffer_direction and buffer_basis on pools, defaulted so the arithmetic is bit-identical to before. Direction earns its place — 5% cap, 8% loss, investors take 3% under FIRST_LOSS and 5% under EXCESS, and a formula that quietly assumed one would be wrong half the time with nothing on any screen to show it. Basis is R8 one layer up: deducting a buffer from a loss already reported net of it counts the same dollars twice, so NET forces the cap to 0. 0119 then makes an inert buffer unrepresentable (buffer_rate_bps > 0 requires external_fund_id, since 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.

A guard was added and removed inside two days. 0118 also carried nav_proposals.recovery_flag, warning when a proposal RAISES NAV because an increase applies with no timelock. That describes the timelock, not a missing control — approving is already a deliberate human act, and a second banner on the same click decides nothing. 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.

Two of the blockers in the plan did not exist. 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 had likewise shipped in v3-106. And Q1 answered itself in the source: deposit() and requestRedemption() are external and KYC-gated, so any holder calls them from their own wallet — a backend gate would have bound the people using the UI and nobody else, which is the wrong half. PoolCommonLib.effectiveNav() is the on-chain answer, and four pricing sites take it, including settleNav (an epoch settling inside the notice window was paying exiters the pre-write-down price out of liquidity the remaining holders still owned — the decision text had called epoch pools already satisfied).

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. 0122 retires 0119 and replaces it with the narrower buffer_not_on_tranche_pools (a tranche group prices through the waterfall engine, so it genuinely has no place to apply one), while cadence_requires_external_mapping keeps the mapping requirement for fund_report_cadence_days, 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, plus the marker corrections and the six defects above. forge test 375/375 including the invariant suite; infra 307/307 (the honest figure); PlatformPool 20,835B against the 24,576B limit, 3,741B of margin. Waiting on the dev deploy: R10 and R2·R3 are written and tested but live in the contract. R9 is done — the mirror was verified exact against all 13 reachable pools after the deploy, and the denominator moved the same day. 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.

And 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 supported behaviour rather than a gap, so the handoff document was retired. Stated once so the defaults are not read as safe: buffer_rate_bps = 0 puts nothing but collateral between a realized loss and investors (the confirmed launch condition); a NULL cadence means the product does not tell investors when the next update lands, because 7 irregular reports in 7 months is an observation and not a promise; apy_basis is indistinguishable from its alternative while the reserve is effectively 0; and a NULL write_off_policy changes no arithmetic, since the partner decides that timing and Aset mirrors the figure. Agreeing a real buffer is a PATCH — carrying the direction as well as the rate, since the same 5% leaves investors with 3% or 5% of an 8% loss depending on it. Docs: 06-writedown-nav · 11-db-schema · 26-glossary. Decisions: v3-109. Source: JY (decisions) + CH (implementation), 2026-08-04.

📅 August 3, 2026 — The Yield Pending tab moves into the read model, and the PENDING orphan is fixed by deleting its producer rather than sweeping after it (v3-104, migration 0113)

🌾 A sweeper is permanent machinery for a status only a dead code path could create

Two things shipped from the v3-104 handoff spec. The first was the architecture item: GET /yield-distributions?include_due=true. The Pending tab was the one tab that could not read the same source as the other four, because a due-but-unrecorded period is not a row — it exists only as pools.next_yield_due, and there is no scheduled-period table. So the admin screen joined GET /pools against ?status=PENDING in the browser, invented the estimate (tvl × apy/100 × periodDays/365 — a formula the backend never specified; v3-20 put the estimated amount explicitly out of scope) and kept a second copy of the fee split mirroring computeYieldFees. Two implementations of the same money math, neither tested against the other. The list endpoint now synthesizes those rows server-side with source: 'RECORD' | 'DUE', missed_periods, and a estimated_gross/estimated_net computed by the one fee implementation — which was extracted to lib/shared/yield/fees.ts so a read endpoint could reuse it without dragging Supabase, viem and the notification dispatcher into a GET Lambda. The front end deleted estimateExpectedGross, computePoolNet and every amount computed inside YieldCalcBreakdown, and the Pending tab moved to server paging/sort/search with a CSV export that finally exports the queue on screen instead of a different list.

The second item is where the spec was overruled, and it is the more interesting half. The spec's Ticket 2 was an aged-PENDING sweeper: read on-chain, heal to DISTRIBUTED or mark FAILED, never settle from a cron, notify on every transition. Verifying it against the writers turned the frequency note into the whole answer. The only writer that ever inserted PENDING was the legacy server-key create path, and that path is unreachable in product: the admin UI refuses the server key for any pool with a fund_wallet (fmSignBlocked), fund_wallet is required at create for anything not display-only (v3-26), and a display-only pool has no pool_address, so the handler 400s first. The sweeper would have been a scheduled Lambda maintaining a status nothing alive can produce.

So the producer was deleted. 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 rather than merely unused: default moved from PENDING to PROCESSING (an INSERT that simply omitted the column would otherwise have reintroduced the orphan silently) plus CHECK (status <> 'PENDING'). Backfill is deliberately narrow: a PENDING row with no tx of any kind → FAILED / ORPHANED_LEGACY_PATH; a PENDING row carrying a tx 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 droppedyield_distribution_investors.status uses PENDING legitimately for an unclaimed allocation, so the ban is a table-level CHECK.

The cost, stated: server-key yield distribution is given up permanently. A pool model where the backend key holds on-chain depositYield permission cannot be used again without reversing this — a contract-permission decision, not a code change. Acceptable because v3-26 already requires non-custodial fund_wallet pools.

What deleting the path left behind, and what got built for it. One real failure mode survives: a PROCESSING row whose deposit landed but whose /distribute never ran — the yield is in the pool and holders have not been paid. Recovery already existed; nobody noticing was the gap. So dashboard_alert_counts.pending_yield was renamed stalled_yield and repointed to PROCESSING older than 24h. Renamed, not just repointed: "pending" is what the Yield screen calls periods still to record, so the old name aimed readers at the wrong queue — and leaving the alert counting an impossible status would have made it read 0 forever, which is worse than wrong, because a permanently-quiet alert looks like a working safety net. The dashboard alert is now "Stalled Distributions", fund-detail's tile "Stalled Yield", and the Yield screen's Failed tab lists them under Stalled with the table's own action (Distribute where there is a deposit tx to verify; disabled with the reason where there is not — a row without one came from the removed server path and is reconciled by the indexer, so retrying it risks paying twice). ⚠️ The matching ADMIN notification is not built — blocked on copy: notifications/catalog/ mirrors 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.

Two deliberate deviations from the spec text, both commented at the call sites: the new response fields attach only when include_due=true (§3 said "every row", §5 required a byte-identical response without the flag — the regression guard is the safer reading), and 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 ships before 0113.

Known reduction, and a limit. 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 exact defect this removes — or a preview endpoint that was out of scope. The FM signs depositYield with the gross, so the missing net does not gate the decision and the final net is on the receipt; the batch modal's net column went for the same reason. And 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 could omit a record that sorts inside it — the only consumer asking for due rows is the Pending queue, whose record side is empty by construction.

infra tsc clean · 223/223 (21 new, covering the estimate against computeYieldFees on identical inputs, missed_periods including the spec's D+115 · 4 periods case, the null-not-zero no-basis path, the SGT month-boundary period label and the merged ordering) · three guards clean · admin-web tsc -b + production build clean. Due-row selection was verified against the live dev DB (7 candidate pools, 1 superseded by an open record, a 118-day-late QUARTERLY pool correctly owing 2 periods, a zero-TVL pool correctly estimating null). Migration 0113 applied to dev. Docs: 14-decisions v3-104 · 11-db-schema · 15-api-reference · 13-operations · 18-failure-types · 10-status-machines. Source: CH, 2026-08-03.

July 2026 71 entries

📅 July 31, 2026 — The notification system rebuilt: one row was doing two jobs, and every awkward part came from that (v3-108)

🔔 An in-app notification the investor had read could carry `status = 'FAILED'`

That sentence is the whole diagnosis. notification_logs was a single row that was BOTH the in-app inbox item and the email delivery log, so its status described the email while the row was the thing the investor was looking at. SUPPRESSED existed only to say "email skipped, in-app fine". The WEBHOOK enum value was unusable because one row had one channel column. And recipient_id meant user_id or pool id or fund id depending on the row — which is why the send worker carried an 80-line resolver, and why an FM notice for a pool with no fund_id reached nobody with no email, no feed entry and no error.

Asked to design it from scratch against today's requirements, the answer is the conventional split, so that shipped: notification_events (what happened) → notifications (who is told — the inbox item, with no status) → notification_deliveries (how it went out, one row per channel), plus email_suppressions. Migration 0110 drops the old table with no backfill.

Fan-out moved to produce time, and that is the change with the most consequences. Read state is now per person — a teammate opening an alert no longer clears it for the team. Preferences apply where the channels are chosen, so an opt-out means no delivery row and therefore cannot appear in a failure count (SUPPRESSED is gone). An unresolvable audience fails at the call site. And the admin feed's fund-isolation logic was deleted: an FM cannot see another fund's rows because those rows are addressed to other people.

Who receives an event is now a property of the event. Each catalog entry declares audience: ['opsTeam', 'fundManagers']; the producer passes { poolId } and nothing else. Deriving that mechanically from all 32 producers found a live bug: fund_member_changed — "Your role and permissions have been updated" — resolved to the entire ops team, true for exactly one reader. Dedup became a constraint too: idempotency_key UNIQUE replaced four separate fail-open lookups that each let duplicates through whenever the lookup itself errored.

🔴 And the bounce gap, which was independent of all of this. SendEmail succeeding only means SES accepted the message. Nothing listened for the bounce that arrives minutes later: the configuration set existed, the BOUNCED / SPAM_FILTERED enum values existed, 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 by MessageId and suppresses permanent bounces and complaints. ⚠️ Attach SesFeedbackTopicArn to the SES configuration set at deploy or the feedback is still thrown away.

Delivery is a queue now (SQS + DLQ), with real backoff (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. The email renders at send time from stored variables, so a copy fix reaches anything still queued and a thousand-holder distribution no longer stores a thousand copies of the same HTML document.

Sheet coverage: 52 of 54. epoch_demand_finalized and over_funding_detected stay blocked on the epoch demand snapshot (A1·A2) — without it the notice would state a "confirmed" total that can still change. Four sheet-vs-code recipient divergences were found and deliberately left alone; changing who gets an email during a structural refactor is the wrong risk, and one would have removed admin visibility of a reserve shortfall. With product.

infra tsc clean · 196/196 · three guards clean · cdk synth clean. The producer guard earned its keep mid-rebuild, catching that fm_notification_failed's only producer was the worker being deleted. Deployed to dev with 0110 applied, then verified against live SES: a bounce and a complaint each round-tripped through send → SNS → webhook in under four seconds, were correlated by provider_message_id, and suppressed the address with the right reason. The event destination that carries that feedback now lives in SesStack beside the configuration set — created in ApiStack it had been a manual per-environment step, and dev had been discarding every bounce behind a pipeline that looked fully deployed. Docs: 14-decisions v3-108 · 22-notifications · 11-db-schema. Source: CH, 2026-07-31.

📅 July 31, 2026 — Model B is merged and undeployed: the setter that installs the schedule has no caller (v3-107)

🧩 Everything about the anchored schedule is built except the call that turns it on

Audit of the write paths before the epoch FE work starts, against apps/contract/src / apps/infra / apps/web. The engine is merged; the plumbing that installs and operates it is not.

🔴 setEpochSchedule has no caller anywhere. Not the deploy config-call list (lib/shared/contract/create-pool.ts), not a lambda, not the admin app — its three arguments are not even fields on CreatePoolOnChainParams. The backend collects funding_anchor_date / recall_lead_days / request_window_days, validates them, and stops at the database. So every pool deploys with fundingAnchor == 0, PoolCommonLib.hasAnchoredSchedule reads false, and the request-window gate is skipped: the lazy clock is back and requests are accepted in the gap. v3-100 framed this as "Model B is a new-pool property, not platform-wide"; with the caller missing it is no pool's property, and the FE premise that deleting the 7 legacy pools leaves only Model B pools does not hold. Two constraints make it worse than a missing line: the setter validates recallLeadDays + requestWindowDays < epochDurationDays so it must run after setEpochDurationDays, and it is create-only — a pool that takes its first deposit unanchored can never be anchored. This blocks pool creation, not just the front end. Decided: wire it, assert the anchor post-deploy, and treat DB-has-schedule / chain-has-none as a deploy defect rather than a supported configuration.

The 확정 / 예정 badge gets its columns. v3-105 decided the badge reads the database and 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 — provenance has to re-arm per cycle or the badge inherits "확정" from the previous one. Neither column exists, so this is a hard FE blocker next to previewEpochClaim, not a detail.

Update 2026-08-04: both columns are applied to dev (migration 0111_epoch_funding_date_provenance). This blocker is closed. previewEpochClaim was a separate axis and is now closed too — deployed to dev 2026-08-04 (below); ⚠️ new pools only, so an FE needs a pool created from the new factory.

Two more write paths and one read path. setEpochSettleAfter also has no caller, so the "publisher is late, move settlement" half of the semi-auto knob is unreachable (SettleAfterTooLate(cap) should surface the cap — the operator has no other way to learn it). The indexer has no writer for any of the three schedule events, so nothing mirrors the installed terms, the confirmations, or the delays; all three are GovernanceLib-declared and therefore need ABI fragments, which recovers them retroactively for deployed pools. And GET /pools/{id} omits all six schedule columns from DETAIL_SELECT while LIST_SELECT carries them — the investor pool detail page reads them as null, so no date, badge, countdown or timeline can render at all.

Recorded for the first time — C7: a claim never expires. Decided 2026-07-24, never written into docs. No deadline, no sweep, no reversion to remaining holders: time-based forfeiture of an investor's money is untenable in an RWA context, and the reservation is isolated in redemptionCommitted so an unclaimed payout blocks nothing. Accepted cost: redemptionCommitted never self-clears, permanently reducing the wind-down numerator and tightening the hold-back clamp. Recorded so it is not later "cleaned up" into an expiry.

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. 08a's executeEpoch row was wrong three ways at once ("No money moves" · "impl pending (CH)" · "per-epoch pot") and the function tables never listed the epoch setters at all — setEpochSettleAfter appeared nowhere in docs. 10-status-machines still carried "⚠️ Engine redesign decided — impl pending". 15-api-reference still advertised propose_extend / execute_extend / cancel_extend on POST /pools/{id}/freeze, which return 410 Gone. 08-smart-contracts still listed the FreezeExtend* events as a live timelocked path. All corrected.

Docs: 14-decisions v3-107 · 07-redemption · 08a-contract-reference · 10-status-machines · 11-db-schema · 15-api-reference · 08-smart-contracts. Source: JY, 2026-07-31.

📅 July 31, 2026 — Epoch stale-doc sync pass (no decision), and the hold-back turned out not to release itself

🧹 The epoch flow section still described an engine we replaced

Sync pass over the pages that carried pre-redesign epoch design, re-verified against apps/contract/src rather than against the earlier docs. Four things were wrong in ways a reader would act on.

The step-by-step flow (07-redemption §Flow) was pre-redesign throughout. It said settlement performs "no money movement" — it does: _reserveFilledGross debits the filled gross epochFundTopUp[id] → heldFundReleases → reserveBalance and parks it in the redemptionCommitted scalar, which is the whole double-commit fix. It described a single fillRatio where the engine fills carry first, then new demand, each pro-rata within its tier. It gated settlement on currentEpochEndsAt rather than settlementAllowedAt (the funding date, delayable only up to + recallLeadDays). It carried the future tense on two things that have shipped — the request-window gate and yield-stops-at-request (YieldLib subtracts escrowed LP from the distribution denominator). It put dust "→ reserve" when floor dust stays inside redemptionCommitted, unswept. And it omitted that lpRemaining only shrinks at claim, which is the fact the whole v3-105 cancel gate rests on.

Model B had no home. The window semantics existed only as a clause inside a v3-93 amendment paragraph, so the page never plainly said "requests are refused between windows." Now a section of its own with the three axes (epoch_duration_days cadence, request_window_days, recall_lead_days), the backwards derivation from the funding date, an ASCII timeline, the distress override, and the legacy-pool carve-out. Related: epoch_schedule_type is documented as a DB label — there is no scheduleType on-chain, the cadence is epochDurationDays with MONTHLY → 28 / QUARTERLY → 84 mapped BE-side, which is exactly why the contract does no calendar arithmetic. (The "28/84 dropped" error itself was already corrected in v3-91; what remained was 04-pool-models still naming scheduleType and still calling the withdrawn epochClaimable pot an open item.)

🔴 The hold-back does not auto-release — and nothing in the product can toggle it. Both this changelog's ancestor text and v3-26 (epoch) said the 90% hold-back is "auto-released when the backlog clears". Reading GovernanceLib: the only trigger is setFundingRestricted(false), which runs _releaseHeldFunds once inside that transaction, clamped to physicalBalance − (reserveBalance + redemptionCommitted + unclaimedYield), with the unreleasable remainder left in the bucket. Nothing re-attempts it. And setFundingRestrictedOnChain has no caller in any lambda or admin screen, so the enforcement lever is currently reachable only by a direct contract call. Two knock-ons: the lever is effectively unavailable in product, and the shipped holdback_release_deferred notification told the FM the remainder pays out "automatically once those claims settle" — a CLAUDE.md rule 2-b defect in live copy. The false claim is removed in the same pass (sheet + registry): both strings now state the condition without asserting a mechanism, and the unit test that had been asserting /automatically/ — the reason the claim survived review — now guards the inverse. Making the payout genuinely automatic needs a sweep that calls setFundingRestricted(false) once the committed debt clears; not built.

⏸️ The event is on hold and the wording is provisional (JY, 2026-07-31). The hold-back has no product path, so FundingRestrictedSet(restricted=false) cannot occur through any Aset surface and the entry, producer and copy are all dormant. Removing the false claim was still the right move — false copy does not get to wait in the registry for a feature, since a manual contract call would send it — but that is not sign-off: final wording is decided when the hold-back is implemented post-launch, when the lever's real behaviour (and whether a release sweep exists) is known. Marked [HOLD] in the copy sheet, deliberately not mirrored into inAppTitle; priority stays critical. No send-blocking gate was added — the event cannot fire, and that guard belongs to the hold-back design.

Reserve and hold-back are now documented as separate buckets. 23-money-path gains the four obligations that share one pool balance (reserveBalance / heldFundReleases / redemptionCommitted / unclaimedYield) with the backing invariant, then a side-by-side table for the two that get conflated: reserve is the pool's own buffer and absorbs NAV loss; the hold-back is the partner's money, parked, is never added to reserve precisely so it cannot distort loss absorption, and is spent before reserve. Same page gains the wind-down numerator (reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted) with the explicit note that nav = reserve / totalSupply was the 🔴W liquidation bug, fixed and shipped in v3-100 — not a general NAV floor, and not still open.

⚠️ "reserve … absorbs NAV loss" above is retired by R8 (v3-109, 2026-08-04). The reserve absorbs no loss — it is investor money already inside the priced claim, so netting it double-counted; it is redemption liquidity only. The rest of this entry (the four shared obligations, the hold-back distinction, the wind-down numerator) stands. Kept as written for history.

Pages touched: 07-redemption · 23-money-path · 04-pool-models · 14-decisions (v3-26 epoch hold-back bullet corrected). vitepress build clean. NAV/tranche governance from the same review is v3-106. Source: JY, 2026-07-31.

📅 July 31, 2026 — A wiped Junior was being impaired in the database only, and the write-down needed no second signature (v3-106)

🧨 The tranche waterfall marked NAV down on-chain, then escalated off-chain

POST /tranche-writedown does the hard part correctly: it decomposes one group loss by subordination and pushes each pool's new NAV through the same updateNAV the manual path uses, with a pre-flight simulate on every affected pool so a predictable revert surfaces as a 4xx before anything moves. Then, for a Junior it just wiped, it writes lifecycle_status = 'IMPAIRED' and impairment_proposed_at = now() straight to pools and calls nothing on-chain.

That is the v3-92 failure again — the database asserting a state the chain does not have — and this instance has no way out. On-chain the pool stays ACTIVE, so the gates that read the lifecycle keep behaving as ACTIVE: the lockup and penalty waiver does not apply, meaning an investor in a wiped tranche is still lockup-blocked or still charged the early-exit penalty while the UI tells them distress terms are in force. And the recovery paths are all closed: propose 409s on both the timestamp and the non-ACTIVE status, execute clears its own DB-side timelock check and then reverts on-chain with NoImpairmentProposal, and setLifecycleStatus refuses IMPAIRED by design.

Decided (v3-106): the wipedOut branch calls proposeImpairmentOnChain, the DB records proposed only, and the label flips at executeImpairment after the 7-day timelock like every other impairment. Deposits that must stop sooner stop via is_paused plus the on-chain pause(), not a lifecycle write. The delay is the feature: IMPAIRED is a public solvency signal with an investor-visible waiver attached.

Two more calls with it. The endpoint moves to ADMIN / SUPER_ADMIN — it accepts OPERATOR today, so one operator can mark down an entire group in a single call. A4 approval is deliberately not extended to it: a waterfall is one loss decomposed into N NAV writes, and an approval queue per leg would let a group settle half-approved. And only complete exhaustion is IMPAIRED — a Senior that absorbed a partial loss stays ACTIVE with NAV < 1.0, which is v3-12's writedown-vs-lifecycle split applied to tranches, so a group legitimately holds an IMPAIRED Junior beside an ACTIVE Senior.

And a correction to how we have been describing "NAV 0". There is no NAV floor mechanism. The behaviour is three unrelated guards — the contract rejecting newNav == 0, the DB CHECK (nav_per_token > 0), and NAV_FLOOR = 0.000001 living only inside tranche.post.writedown.ts — plus epoch settlement's separate corrupt-oracle guard. So zero is not representable anywhere: a wiped tranche is 1e-6, and the lifecycle transition rather than the number is what states "effectively zero". ⚠️ Latent: with navDeviationCapBps > 0 a full wipeout cannot be applied at all (1.0 → 1e-6 exceeds any cap). Not live — nothing calls setNavDeviationCap — but whoever enables it needs a lift → write down → restore step first.

The wind-down numerator item bundled into the same page was already shipped, not open: executeWindDown has priced liquidation off reserveBalance + heldFundReleases + totalEpochTopUp − redemptionCommitted since v3-100. What survives from it is copy — the pools.post.wind-down notification text and the PlatformPool.sol:762 docblock still describe the reserve-only formula. Docs: 14-decisions v3-106 · 04-pool-models · 06-writedown-nav · 10-status-machines. Source: JY, 2026-07-31.

📅 July 31, 2026 — Cancelling after a partial fill handed the LP back and left the cash with no claimant: cancel now claims first (v3-105)

✂️ The reservation had no owner, and the window gate was not protecting anything

v3-100 left this with CH: settlement moves the filled gross into redemptionCommitted but burns no LP — both the burn and the payout wait for the claim — while cancelRedemption returns ep.lpRemaining, still the whole position, and leaves the request in a status the claim path rejects. _payCommitted is the only decrement, so the reservation sits there with no claimant, permanently. What settles it as a real defect rather than a corner: cancel is window-gated, settlement happens at the funding date which is after the cutoff, so the only cancel a partially-filled investor can make is one that follows an unclaimed fill. C10's carry-over exception is the path into it, not the fence around it.

The investor is not the loser here — they leave with LP worth their full position. The pool is, twice: the cash left the liquidity buckets at settlement, and an inflated redemptionCommitted is permanently subtracted from the wind-down numerator (remaining holders under-paid by exactly that amount) and permanently tightens the hold-back release clamp (that much can never reach the partner either).

Decided (v3-105): cancel refuses while anything is claimablefilledLp > 0 → revert ClaimBeforeCancel. No new state, no per-request accounting; the UX is claim, then cancel, and the first step is always available because claimRedemption has no window gate. A request that already claimed reads filledLp == 0 until the next settlement, so the ordinary cancel is unimpeded.

Update 2026-08-05: shipped. The revert is in RedemptionLib.sol:626, deployed to dev 2026-08-04 (new factory → new pool implementation, commit 0abe168). ⚠️ New pools only — pools created before that deploy are clones of the old implementation and still return the full position.

"Return only the unfilled remainder" turned out to be unbuildable 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 number already net of it. Fixing that means re-anchoring the request to pure-carry form — exactly what _settleEpochClaim does. Every version of the option settles the filled slice first, so it is the decision above with extra state. Auto-claiming inside cancel was also rejected, though it would save a transaction: claim gates on canRedeem and requestHeld, so folding it in means a KYC-revoked or held investor can no longer cancel at all — and cancel is deliberately un-gated precisely so nobody is trapped holding LP they cannot release.

🔴 This blocks the front end on a missing view. The split is not computable from public state: epochRequests returns (epochId, principalLp, lpRemaining, yieldAccSnapshot) with no gBase / hBase / generation, and epochH / epochCarryGen / generationCloseH have no getters at all. So previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD) is required, not nice-to-have — without it there is no "filled 60 / rolling over 40" display, no honest cancel copy, and no way to explain why the gate fired. _epochFillMath is already view; this is a wrapper.

Update 2026-08-05: shipped. previewEpochClaim (PlatformPool.sol:317RedemptionLib.sol:703) is on the implementation deployed to dev 2026-08-04 — verified by selector 9ccd0389. ⚠️ New pools only; on a pre-existing pool the call still reverts, so the FE needs a pool created from the new factory to test against.

Second half: the "확정 / 예정" badge reads the database, and today it would always say 예정. A stored on-chain date does not mean a confirmed one — settlement materializes the fail-open date into the same slot with no event, and cycle 1 is backfilled from the anchor, so a raw getter would report "confirmed" for dates nobody confirmed. A real flag needs new storage and a new view, and pools are EIP-1167 clones, so it would cover new pools only. Hence: provenance lives beside pools.next_funding_date, which the front end already reads. But nothing in apps/infra calls setEpochFundingDate — the admin edit path stops at the DB — so every on-chain cycle date is currently a fail-open derivation and the confirm action has to reach the chain before the badge means anything. That also gives the SEMI_AUTO reminder something to be a reminder for. For audit, EpochFundingDateSet is the only exact confirmation signal; it is declared in GovernanceLib so it is missing from PlatformPool.abi.json, but the log is emitted under the pool address — adding the fragment recovers it retroactively, with no contract change.

Open: with the claim-first gate in, a KYC-revoked epoch investor can neither claim nor cancel, and rejectRedemption never accepted QUEUED / PARTIALLY_FILLED — so no admin action can close the request. P1. Docs: 14-decisions v3-105 · 07-redemption · 08a-contract-reference. Source: JY, 2026-07-31.

📅 July 31, 2026 — The admin Yield list has been computing money in the browser: due periods move into the read model (v3-104)

💰 The Pending tab was a front-end join over two endpoints, with its own fee math

The other four Yield tabs list yield_distributions rows. Pending lists pools that have no row yet — a distribution record is only created when someone records a gross, and until then the obligation exists solely as pools.next_yield_due / yield_overdue. There is no scheduled-period table. So the tab joined GET /pools against GET /yield-distributions?status=PENDING in the browser and, to fill the amount column, invented the estimate (tvl × apy/100 × periodDays/365 — the v3-20 spec had put the estimated amount explicitly out of scope) and kept a second copy of the fee split mirroring computeYieldFees. Two implementations of the same money math, one of which nothing tests against the other: a fee-policy change in the backend leaves the screen's numbers stale without a single failure.

Decision (v3-104): GET /yield-distributions?include_due=true. One payload, real records plus synthesized due rows, discriminated by source: 'RECORD' | 'DUE', each due row carrying its pool, period label, due date and a server-computed estimated gross/net from the same fee code that settles a real distribution. Server-side sort, search, paging and role scoping, like every other tab. The admin front end drops to display only.

Materializing a DUE row in yield_distributions was rejected. It is the literal "one table", which is exactly the hazard: rows carrying no money in the ledger table. 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 one missed exclusion overstates what was paid to holders. The ledger holds facts; a list that mixes facts with obligations is a read model.

Two parameters of that estimate are settled with it. The basis is APY on TVL, tagged estimate_basis: 'APY_ON_TVL' on the row so a later change is visible — anchoring on the previous period's actual gross was rejected as empty for a first distribution and stale once TVL moves. And a pool gets one due row carrying missed_periods, with the estimate covering every missed period: the daily sweep does not roll a missed next_yield_due forward, so a 4-months-late pool would otherwise show a one-period figure and understate what is owed by 4×.

🔴 And the thing the spec walk-through turned up: a PENDING row has no owner. Only two writers insert into yield_distributions. 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 inserts PENDING and settles inside the same invocation, and the indexer only writes DISTRIBUTED. A row sitting in PENDING is therefore a crash orphan between insert and settleYield, and nothing heals it: the indexer reconciles on tx_hash, which that row has not been given yet, and yield.scheduler.reconcile only mirrors claimable_yield. The period reads as un-distributed forever with no way to tell from the DB whether holders were paid. Frequency is low (the PENDING-inserting route is the legacy server key, which the admin UI refuses for any pool with a fund_wallet, i.e. every real on-chain pool), the consequence is not. A sweeper is decided, not built — handoff Ticket 2, and a correctness item rather than an architecture one. (Superseded 2026-08-03: the sweeper was never built. Since the PENDING-inserting route is unreachable in product, the route was deleted and migration 0113 forbids the status outright — see the August 3 entry above.)

What did change in the product today. The Pending tab now renders through the shared DataTable, so it has the same column headers, sortable headers, search row and pagination footer as the records tabs instead of a hand-built row list (columns map one-for-one: Date → Due, Total Amount → Est. Amount, Status → overdue D+n). Its pool-name link had carried flex-1, so the anchor box covered the row's free width and every click meant as "select this row" navigated to the pool instead. And the stuck rows moved out of it: that tab is the queue of periods to record, so its badge now equals its rows (the badge, the table and the Overview panel had been showing 7, 8 and 1 for three different meanings of "pending"), while Failed becomes the attention tab — the FAILED table plus a Stuck table of the PENDING orphans, no row action, rendered only when non-empty so it disappears once the sweeper ships. Overview's Pending row is relabelled Stuck for the same reason. (Superseded 2026-08-03: Stuck became Stalled and now lists PROCESSING rows older than 24h — PENDING is impossible after migration 0113, and unlike a PENDING orphan a stalled row has a safe recovery, so the table keeps its Distribute action.) Docs: 14-decisions v3-104 · 13-operations · 15-api-reference · 18-failure-types. Source: JY, 2026-07-31.

📅 July 31, 2026 — The notification docs catch up with the code: 56 keys, the code is the copy's source of truth, and nothing is being delivered (v3-103)

📮 Two pages were describing a system that had already been fixed

22-notifications and 13-operations still carried the 2026-07-28 audit: eight producers bypassing the copy registry, freeze milestones with no producer, investor preferences unenforced, impairment/wind-down copy that nothing sent. All four were fixed in the ch/product work and none of it had reached the docs — so the pages were pointing readers at gaps that no longer existed while missing four events that now do. Re-verified key by key against copy.ts and the producer map: 56 registry keys, guard clean.

The copy's source of truth is now the code (v3-103). copy.ts and the Notification Copy sheet were co-edited by hand and drifted in both directions, with no rule for which won. Every real difference in the audit favoured the code — a documented reason or a bug fix behind each — and the code is what renders. The sheet becomes a view of the registry, and the recommendation is to generate it rather than hand-sync, because a hand-synced mirror drifts again. The one sheet row that must be corrected first is redemption_rejected, which still carries {reason} in its detail rows: that is the operator's internal AML note, and rendering it to the subject is both a PII leak and a tipping-off risk.

Four pending copy calls closed with it: holdback_release_deferred is critical (it is the only message explaining a money discrepancy the FM would otherwise read as an error); fund_member_changed keeps two perspectives — second-person email to the member, third-person in-app, because an ADMIN row is a shared ops feed and not a personal inbox; operator scoping stays deferred explicitly, since splitting operators off the ADMIN feed means a new recipient_type, an audience decision across 32 admin-app keys, and no operator-only event yet to pay for it; and the no-em-dash rule now covers notification copy. That last one was the open question here yesterday and is settled: notification strings are user-facing text that leaves the platform, so exempting them would have left the product's most-read copy as the only surface allowed to sound machine-written. Replacement is a spaced hyphen; the sheet is already converted and the sweep over every copy.ts entry is a BE task (dev handoff §B3).

What the docs were missing. Four new events — redemption_exit_gate_blocked, epoch_funding_date_due, holdback_release_deferred, pool_lifecycle_active — plus the *_ops awareness variants, the freeze auto-expiry sweep that makes freeze_exit_window_open / freeze_expired real, and investor preference enforcement (live 2026-07-30, migration 0108). pool_lifecycle_active is the first optional investor event, which is what makes that enforcement observable at all; its recipients are an explicit opt-in list (pool_follows, 0109) because its copy says "you asked to be notified", which is false of anyone who did not. Gone the other way: freeze_extended, along with the on-chain extension it reported — a 7-day timelock on a 7-day freeze could only fire after the freeze had lapsed, or retroactively restart the 72h exit block.

🔴 And the thing that outranks all of it: nothing is being delivered. The send worker is not consuming SENDING rows and the last DELIVERED row is 2026-07-03. Producers, copy and payloads are complete and deployed to dev with migrations 0100–0109 applied — so every "live" claim on these pages is about a code path, not about mail arriving. The EventBridge rule exists, which makes "which commit is actually deployed" the first question rather than "why is the worker broken". The backlog hazard is already cleared (1139 → 8 rows), so the drain can be turned on without a storm. Both pages now open with that caveat instead of implying delivery.

13-operations' per-event matrix was removed rather than updated: a 13-row snapshot of a 56-key registry kept being read as the inventory. What stays there is the operating rules — channels, critical-vs-optional, retry.

The schema page was carrying the same drift, in two directions at once. 11-db-schema was verified through 0107, so it had neither notification migration: 0108 (investor preferences re-keyed on event_key) and 0109 (pool_follows). Worse, its header said 46 tables through 0107 while its own footer still said 40 tables through migration 0050 — two counts on one page, disagreeing by seven tables and 59 migrations. Both now read 47 tables · 26 enums through 0109, verified against schema.sql, and the _(pending dev apply)_ markers on 0100–0107 are cleared: that batch reached dev on 2026-07-30. Docs: 22-notifications · 13-operations · 11-db-schema · 14-decisions v3-103. Source: JY, 2026-07-31.

📅 July 31, 2026 — A yield period is now one transaction: the fee leg can no longer fail behind a successful distribution (v3-102)

🧾 The backend recorded a fee it had not collected

runYieldDistribution called distributeYield(net), then withdrawFees(...), and .catch()'d the second into a console line. A failing fee leg therefore left holders credited, the fee uncollected, and the row finalized DISTRIBUTED with fee_amount written as if it had been taken — and nothing could fix it: POST /{id}/distribute returns early on a DISTRIBUTED row, and withdrawFees had exactly one caller, so no product surface could retry. Only a direct contract call from outside the platform would have collected it. The money was never lost (it stays in unclaimedYield), but the database asserted a payment that had not happened — and the residue permanently inflates unclaimedYield, quietly shrinking what a hold-back release pays the partner. The realistic triggers were mundane: a pool with pool_mgmt_bps but no fund_fee_wallet set (an instant, un-timelocked setter — an easy config gap), or any RPC hiccup.

settleYield(stablecoin, net, treasuryFee, poolMgmtFee) replaces both, and both old entry points are removed so there is no second way to settle a period. Either it settles or nothing happened on-chain. Two things came with the merge: the net credit and both fee legs are now bounded against unclaimedYield in one check before any state moves — which promotes an invariant 09a-custody had explicitly recorded as a Lambda call-ordering convention into an on-chain bound — and the pool got smaller, 21,064 → 20,929 B (EIP-170 margin 3,512 → 3,647), because two wrappers became one. YieldDistributed / FeesWithdrawn keep their signatures, so the indexer is untouched.

The signature mixes scales on purpose: netAmount is normalized (18, ledger-only) while the two fee legs are raw (each wraps a transfer). That is v3-101's rule applied, not an exception to it — and the branded backend types make the two non-interchangeable at compile time, which matters because on this path the wrong scale still does not revert.

Why we were willing to touch the contract: pools are immutable EIP-1167 clones behind a factory with an immutable implementation, so this costs a new implementation, a new factory, and recreating every pool — cheap only because no distribution has ever run. The alternative was carrying a swallowed-failure path into production for good.

Also closed: the backing invariant was asserting less than its own docblock claimed. An earlier read called this a live leak in _releaseHeldFunds; that was wrong — the release is clamped to heldFundReleases, which is itself inside the invariant. It was a verification gap, and the cause was a name. unclaimedYield()'s natspec said "deposited and distributed but not yet claimed by holders" and the fuzz test's docblock said it existed to protect "distributed-but-unclaimed yield" — while the field is decremented at distribution. So the term everyone believed was covered had never been in the sum. Distributed yield lives only in the per-user accruedYield mapping with no aggregate, so the fix belongs in the harness: Σ pendingYield(investor) is now part of the invariant, verified non-vacuous (the term reaches ~49e18 in a run). Both comments corrected; no new on-chain storage.

And the read direction of the unit guard, deferred one day earlier, is now enforced. v3-101 left formatUnits out because 12 sites would have needed 12 exemptions on day one. All 12 were converted — amounts and NAV onto typed decoders (fromRaw / fromNormalized / fromNavPrice), LP balances and fill ratios onto normalizeAmount with a named scale — so the guard now covers both directions with zero exemptions. Converting them surfaced one more structural fix: the instant-redemption payout mirror spanned three scales inside a request handler, and moved to lib/shared/contract/ beside computeInstantShortfall where mixing axes belongs.

One more red test, unrelated but worth the detour. PlatformKYCSoulbound.test_RenewExtendsExpiryInPlace failed under the dev and prod profiles and passed under the default one, so make test-prod had never been green. solc may treat block.timestamp as constant within a call — true of a real transaction, not of a test that warps — and with via_ir's stronger CSE the second block.timestamp + ONE_YEAR folded into the first, renewing to the already-expired date. Fixed by deriving the target from expiresAt.

forge test 369/369 on both dev and prod profiles (was 365/1 before this work). 3 new atomicity regressions + the strengthened invariant. ABI regenerated (263 → 262). infra tsc clean · 162/162 · four guards clean · both frontends typecheck against the shrunk ABI. ⚠️ Not deployedsettleYield does not exist on deployed clones, so yield settlement is blocked until a new implementation + factory ship and pools are recreated. Docs: 14-decisions v3-102 · 05-investment-lifecycle · 23-money-path · 09a-custody · 8 further pages updated off the removed function names. Source: CH, 2026-07-31.

📅 July 31, 2026 — The 1e12 bug was not in the contract: amount axes become types, and a second live instance surfaces in NAV write-downs (v3-101)

📐 A wrong scale here never reverts — that is the whole problem

JY's handoff asked whether to fix the backend or change the contract so all three yield steps take raw amounts. Neither: the contract is already consistent. All eight amount-taking entry points follow 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; distributeYield / claimYield / reinvest / reserveConsumed / setHardCap / the config caps meet ledger counters. So "the middle yield step is the odd one out" was a misreading of a transfer function versus an accounting function, and unifying on raw would have forced a currency onto unclaimedYield, which sums every accepted stablecoin. Table now published: 08-smart-contracts → Amount Units.

The root cause is that the axis was only ever written in prose. It was documented — @param netAmount ... (normalized to 18 decimals) in PlatformPool.sol, plus a YIELD_NORMALIZED_DECIMALS = 18 constant sitting in the same backend package that got it wrong — and the slip happened anyway. It cannot fail loudly either: every on-chain check on these arguments is an upper bound, so a value 1e12 too small passes, the tx succeeds, and the off-chain records stay plausible because they are computed separately. The mismatch waits for an investor's claimYield to revert. So the axis is now a branded type (RawAmount / NormalizedAmount / NavPrice in lib/shared/contract/units.ts): every on-chain amount parameter declares its axis and the wrong one is a compile error, with scripts/check-onchain-units.mjs (in infra build + test) banning parseUnits outside the converter module.

Applying the types found a second live instance nobody had reported. updateNAV(reserveConsumed) was parsed at 6 decimals in both nav-changes.post.propose and nav/apply-proposal, but the contract debits it straight out of s.reserveBalance — an 18-decimal counter. Every write-down therefore burned essentially none of the reserve and reported success, leaving the reserve overstated for redemption fills and the wind-down NAV. Same signature as the reported bug: too small, no revert, plausible records.

The reported one has no live damage — no real distribution ever ran (yield_claims = 0, yield_distribution_investors = 0, both DISTRIBUTED rows are seed data), so no on-chain accumulatedYieldPerShare is skewed and no migration is needed. YieldDistributed.netAmount had to be re-decoded at 18 in the same change, because the indexer read it at 6 and thereby cancelled the bug in the recorded number; fixing one side alone would have broken the record. yield_per_share was never cancelled and was simply 1e12 low.

Scope stated rather than implied: the guard covers argument construction (parseUnits), not the read direction (formatUnits, 12 sites) — a wrong scale there is a visible display error, and banning it today would have meant 12 escape hatches on day one. Two contract-side items stay open for their own reasons, neither about units: merging steps 2+3 into one settleYield for atomicity (a swallowed withdrawFees failure can leave net-distributed-but-fees-uncollected, and a retry double-distributes), and the backing invariant missing distributed-but-unclaimed yield, which lets _releaseHeldFunds release balance backing accruedYield.

infra tsc clean · 150/150 tests (9 new axis/boundary tests) · copy + producer + new units guard clean · guard verified against a planted violation. ⚠️ Not deployed. Docs: 14-decisions v3-101 · 08-smart-contracts → Amount Units. Supersedes the A/B choice in yield-distribute-scale-mismatch-handoff.md. Source: CH, 2026-07-31.

📅 July 30, 2026 — The hold-back clamp gets a voice; building it found four money notices off by 1e12, and one live on-chain one (v3-100)

💰 A notice whose only job is to state an amount, added next to four that were stating the wrong one

holdback_release_deferred (FM, in-app + email) closes the gap v3-100 opened: lifting the 90% hold-back now pays out only the part of the bucket not backing settled-but-unclaimed debt, and from the partner's side that is indistinguishable from a release that simply paid less than expected. The indexer watches FundingRestrictedSet(restricted=false); because the event carries no amount, heldFundReleases is read back pinned to the event's own block_releaseHeldFunds ran inside that transaction, so the value there is exactly what stayed behind, while a live read would show whatever later claims had since done to it. Remainder 0 or unreadable → nothing is sent: this message exists only to state a figure, so a wrong number is worse than none. Re-arms daily rather than dedupByEntity, which is one-per-pool-forever.

Then the units. The pool normalizes every USD figure to 18 decimals (PoolCommonLib.normalizeAmount), but the indexer decoded four of them as USDC's 6, overstating by 1e12 — and a mis-scaled amount still renders as a plausible number, which is exactly how it survives review. Fixed: 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 — investor-facing), redemption_pending_reserve's shortfall, and readEpochShortfall, which divided by LP_PRECISION instead of NAV_PRECISION. That last one mattered twice: it compared a 1e6 demand against an 18-decimal balance, so the shortfall computed as ~0 on any real pool and the D-2 / D-12h partner-funding reminders never fired at all. Same class as the earlier G4 penalty fix; now named NORMALIZED_DECIMALS so the split from raw transfer amounts (Deposited.amount, YieldDeposited.grossAmount) is explicit rather than remembered.

🔴 And one that was left alone on purpose. runYieldDistribution converts netAmount with parseUnits(…, 6) and hands it to distributeYield, which expects the 18-decimal scale. Its two neighbours, depositYield and withdrawFees, genuinely do take raw stablecoin units — which is what makes the middle one easy to get wrong. If confirmed, yield is under-distributed by 1e12× and deployed pools already carry a skewed accumulatedYieldPerShare, so this needs CH and a live-state check rather than a one-line edit. The indexer's matching decode is deliberately not flipped: today the two errors cancel, and moving one alone would corrupt the recorded figure. With CH.

Verified: infra tsc -b · 155/155 · copy-guard 632 files · notification-producer guard 56 keys · docs build clean. Docs: 22-notifications · 14-decisions v3-100. Source: JY, 2026-07-30.

📅 July 30, 2026 — Epoch close-out: the pot we specified was never built because it was designed out; v3-93's six open items closed; one live leak found (v3-100)

🔁 Two of our own entries were wrong, and one of them was hiding a real bug

The ch/product merge made v3-91 / v3-93 checkable against code, and the first thing that fell out was a documentation failure. v3-91 specified a per-epoch epochClaimable[id] pot; that design was withdrawn on 2026-07-27 (a claim spans epochs, so per-epoch pots are O(V) and kill the O(1) claim) and replaced by a single redemptionCommitted scalar — but the withdrawal never reached this page. So yesterday's status table asked CH whether the double-commit fix had landed while looking for a field that had been deliberately designed out. The scalar is shipped, with the invariant enforced by Foundry test rather than an on-chain assert, plus the _debitLiquidity / _debitPrincipal split and the setFundingRestricted clamp that the scalar makes necessary. Same class of correction for the wind-down numerator (🔴W): liquidation priced off reserveBalance alone and ignored the two buckets holding recalled money.

v3-93's six open items are closed, all decided 2026-07-27 and only now written down: over-funding leftover rolls forward with an FM notice (🔴 "rolls forward" was wrong — corrected by v3-137 on 2026-08-17; the leftover is bound to its own cycle); admin rejection needs no cutoff gate (epoch requests cannot be rejected at all); no separate regulatory forced-cancel path (holdRequest + the claim-time KYC gate cover it); demand == 0 folds into 🔴F; instant PENDING_RESERVE cancel is harmless; and pending epoch demand at WIND_DOWN is consistent once the distress exception skips the window gate.

🔴 And one live leak, found in the merged code while verifying the rest. cancelRedemption returns ep.lpRemaining, which is only reduced at claim — so a partially-filled investor who has not claimed gets all escrowed LP back while the filled slice's USD stays in redemptionCommitted with no claimant, permanently subtracted from the wind-down numerator and paid by the remaining holders. v3-93 assumed the C10 window gate foreclosed this; it does not — the carry-over exception is precisely the path that reaches it, and the behaviour is pinned, not fixed, by test_C8_CancelRolledOver_ClearsCarryBucket. With CH.

🔴F was the worst of it, and it had no docs entry at all — while 07-redemption published the broken formula as the design. A full fill zeroes the surviving-fraction ladder, and a full fill is the operating goal, so liquidity gating died on the first healthy cycle with all 27 existing epoch tests green. Fixed by restarting the ladder per generation. Two carve-outs to know before deploying: fundingAnchor == 0 keeps the 7 already-deployed epoch pools on the legacy path permanently (so Model B is a new-pool property), and the ep.gBase == 0 fallback covers 2 in-flight live requests that should be checked against old full fills first.

Also corrected: v3-91 had the schedule approximation backwards — it said the 28/84-day week multiples were dropped, when adopting them is exactly why the contract does no calendar arithmetic, which v3-93 already stated. And the wind-down copy gate is now lifted: that decision said "do not switch the wording ahead of the contract", and the contract has moved — totalEpochTopUp is fed by the partner's fundRedemption, so recalled capital does reach the numerator and the "no path exists" premise is false. Whether to adopt "recovered capital" wording is now a product call, flagged in 10-status-machines and v3-95.

Also: funding-date reminder lead 3 → 7 days (LEAD_DAYS, BE pending); hold-back release remainder now gets an FM notice (the clamp can silently pay less than the FM expects, so it needs a voice — copy + producer on the notification track); the 72 h withdrawal block stays as-is per v3-28. Docs: 14-decisions v3-100 · 07-redemption · 04-pool-models · wind-down formula corrected on 6 further pages. Notion "Epoch Redemption — 코드 검수 정리" handoff reply. Source: JY, 2026-07-30.

📅 July 30, 2026 — The AML exit gate moves on-chain: every instant settlement path is checked, and an hourly sweep makes the revert visible (v3-99 contract step shipped)

🔒 The payout entry point is now the gate, not each caller

A holder revoked while their request waited in PENDING_RESERVE could still be paid. Of the three callers reaching _executeRedemptionPayout, only claimRedemptionFallback re-checked canRedeem; approveRedemption and the partner-funding auto-settle inside fundRedemption did not — so the same holder state produced a payout or a refusal depending on which path happened to settle. The check now sits at the single payout entry point and reverts RedemptionBlockedByKyc. Gating there rather than at each caller is the point: the gap exists precisely because a caller was added without the check.

The asymmetry is preserved (v3-31): REVOKED and NONE are refused, an EXPIRED SBT still exits. Tightening that would turn a lapsed document into a fund trap, which is what v3-28 forbids. A refused request is not stuck either — an admin closes it with Return position and the position goes back.

A revert is silent to Aset, so it ships with an hourly sweep over waiting requests that alerts an admin before the partner spends gas on a call that cannot succeed. The sweep changes no state (AML blocking stays Aset's explicitly recorded action, not a cron's) and is deliberately not fail-closed on an unreadable chain, unlike the write-path callers of the same helper: there is no payout and no human awaiting a response here, and alerting on every RPC hiccup would train admins to skip the alert. Also mirrors RedemptionRejected in the indexer, so a reject placed straight on-chain from a multisig no longer leaves the row awaiting a settlement that can never come.

The closure-reason pre-select is wired to the same check, but reads it live rather than off the sweep: a new read-only GET /redemption-requests/{id}/exit-gate asks at the moment the dialog opens, so the hint cannot be an hour stale and works on a request the sweep has not reached. It fails open — unknown leaves the state-based default in place — the opposite of the write paths using the same helper, because a wrong guess here labels a funding closure as a compliance one, and that label picks the investor notice. Closure reasons also moved to @aset/types (they existed twice, so the API validated one list while the panel offered another), and /counts now returns the closure breakdown with CLOSED derived as its sum, so an investor withdrawing their own request is never summed into an admin closure.

forge test 366/366 (3 new regressions confirmed failing before the fix) · PlatformPool 21,232 B unchanged (+3,344 B EIP-170 margin) · ABI regenerated · infra tsc clean · 141/141 · copy-guard + producer guard clean · cdk synth clean. ⚠️ Not deployed — neither the pool implementation nor the infra stack has been released for this commit. Docs: 14-decisions v3-99 · 07-redemption → Exit gates · 09-rbac. Source: CH, 2026-07-30.

📅 July 30, 2026 — The redemption-closure notice splits by reason; the compliance wording is withheld on purpose (v3-99 step 1 shipped)

✉️ One notice could not carry both closure reasons

Return position closes a request either because funding never arrived or because the holder no longer passes verification, and a single redemption_rejected notice was wrong for the first: funding failing is no fault of the investor, so "declined" read as an accusation. Neither wording mentioned the fact that matters most in both cases — the LP came back. The category the operator picks now selects the notice: UNFUNDED → new redemption_returned_unfunded ("position returned, nothing deducted, request again any time"), COMPLIANCE / OTHERredemption_rejected, narrowed to "on hold, contact support".

The operator's note is never rendered to the investor. The mandatory free-text reason is written for a later reviewer and can carry detail that should not leave the admin panel, so neither notice quotes it. The compliance notice also withholds why: naming an AML or verification trigger to the subject risks tipping-off. That wording is pending legal sign-off (Notion "Legal Review Required" #25, five questions incl. whether a consumer-protection duty to disclose outranks the tipping-off restriction) — but only the strings are open. The event branching, the vars, and the failure_type whitelist are final, so a legal answer changes copy, not code.

Shipped: e2e3eca (permission / CTA / reason) + a18a2d7 (copy split). tsc -b clean · 127/127 · admin build + copy-guard clean. ⚠️ Not deployed — infra has no auto-deploy and cdk deploy has not run for these commits, so the branched notice does not fire in production yet. Docs: 22-notifications. Source: JY, 2026-07-30.

📅 July 30, 2026 — Return position is ADMIN-only with a mandatory reason; the AML gate reverts rather than auto-closing (v3-99 closed)

🚪 The three open decisions on the redemption exit gate are closed

One button, not two. A second Block for compliance CTA was dropped. Its main justification was splitting permissions, and that disappeared once the action became ADMIN-only — both cases produce the same result (request closed, LP returned) and differ only in why. That difference now rides on a mandatory reason the system pre-selects (compliance when the sweep flagged verification, unfunded for a stalled shortfall) and the operator can override, plus a required free-text note. Same propose-then-confirm shape as NAV approve/override, so there is nothing new to learn. Pre-selection is deliberately not a lock: a verification flag can be a false positive, and the operator may be closing for the other reason.

ADMIN / SUPER_ADMIN only — FM excluded, and not as 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 comes back, 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 09-rbac allowed it — so the docs and handlers move, not the PRD. 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 AML gate reverts; it does not auto-close the request. A premise in the incoming review needed correcting: reverting does not strand the partner's money, because the transfer and the payout happen in the same transaction, so a revert rolls the transfer back with it. (Split funding can leave an earlier top-up inside, but that predates this change and Return position already returns it to fund_wallet — no new path to design.) Auto-closing was rejected for a different reason: it would let the partner's funding transaction execute Aset's compliance decision and record the partner as the actor. The check goes inside _executeRedemptionPayout, not per caller — one check covers both unguarded paths, and per-caller patching is exactly how the gap appeared.

A revert is silent to us, so it ships with a sweep — not with watching for the failed transaction. A periodic pass over waiting 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 also means the reason is known before the dialog opens, which is what makes the pre-selected reason work at all. Contract revert stays as the last line of defence — the two do different jobs.

Built the same day: permission / CTA / reason in e2e3eca, the copy split in a18a2d7, the contract gate + sweep in the entry at the top of this page. Not deployed. Docs: 14-decisions v3-99 · 07-redemption · 09-rbac. Notion "상환 출금 게이트 & Reject 재정비" handoff §4. Source: JY, 2026-07-30.

📅 July 30, 2026 — Epoch engine status corrected item by item after the ch/product merge

🔁 The redesign landed piecemeal, so a single status badge was wrong either way

Merging ch/product shipped most of the v3-91 / v3-93 engine redesign, which made several pages wrong in both directions at once: they still said "decided but not yet implemented", while a callout written the day before said the schedule columns had no engine reading them. Flipping everything to "shipped" would have been the mirror-image error, because the redesign did not land as one unit.

Verified against the merged RedemptionLib / PlatformPoolStorage and recorded as a per-item table in 07-redemption: shipped — request-window hard gate (RequestWindowClosed), calendar anchor (epochFundingDate + settleAfter + the new pools.scheduler.epoch-funding-date), accepting/settling cursor split (acceptingEpochId), carry-first 2-tier fill (epochCarryDemandLp / epochNewDemandLp / epochAggFillRatio), recallLeadDays window, and yield-stop-at-request (escrowed LP out of the distribution denominator, with the YIELD_BASED penalty basis moved to the yield banked at lock time). Not present — the per-epoch settlement escrow (epochClaimable); no such field exists, so whether the double-commit fix was achieved another way is an open question for CH rather than something to assume either way.

The callout I wrote yesterday about the 6 epoch-schedule columns is now the stale one and has been rewritten. Its claims — no repo migration, and no code reading the columns — were both closed by this merge (0104_epoch_schedule_cols.sql backfills them, schema.sql carries them, and create/update/scheduler all read them). What survives is the part that outlives the instance: supabase_migrations does not record SQL-Editor applications, so the (pending apply) labels in that page's header are intent rather than verified state.

Also retired: LIQUIDITY_WINDOWS was still listed as a pending "API block (3 places)" follow-up under v3-91. Migration 0106 removed the enum value outright and pools.post.create no longer references it, so the follow-up is gone rather than pending.

Docs: 07-redemption · 04-pool-models · 11-db-schema · 14-decisions v3-91. Source: JY, 2026-07-30.

📅 July 30, 2026 — Redemption exit gates documented; the operator action is renamed Return position (v3-99)

🚪 What can stop a withdrawal, and which part is a person

Nothing wrote down what blocks a redemption, so the rules were recoverable only by reading the contract. Two wrong beliefs came out of that gap and are worth naming because both are load-bearing: that epoch pools have no AML gate (they do — the same canRedeem check runs at request and at payout), and that the operator's reject button is how AML is enforced (it is not; that is automatic).

Three gates, now in 07-redemption: lockup/maturity, verification, and the operator action. The verification asymmetry is the part most likely to be "fixed" by mistake: REVOKED blocks the exit, EXPIRED does not (v3-31). Blocking on expiry would trap an investor's own capital behind a lapsed document, which is exactly the failure mode v3-28 exists to prevent, so the doc states the intent rather than only the behaviour.

The CTA becomes Return position. "Reject" describes denying an entitlement, but the action returns the escrowed LP and leaves the holder with their position — a reversal, not a denial. Partner funding already deposited goes back to fund_wallet, and nothing else needs unwinding because no yield is deducted at request time and the penalty is only paid at settlement (v3-84). Release and Freeze were unavailable, already meaning hold/release and pool freeze. The compliance case should be a second, admin-only CTA rather than the same button: one control currently conflates a no-fault cleanup with an AML judgement, and FM can press it.

🔴 Left open, and stated as such: the auto-settled path is not re-checked. _executeRedemptionPayout has three callers and only claimRedemptionFallback checks canRedeem first. approveRedemption and the partner-funding auto-settle inside fundRedemption call it directly, so a request that passed at request time and then waited in PENDING_RESERVE while the SBT was revoked is paid the moment funding lands. The fix belongs inside _executeRedemptionPayout — one check covers both unguarded callers, whereas patching each caller guarantees the next one misses it. Until it ships, the manual Return position is substituting for an automatic control, which is not an acceptable resting state for an AML gate, so the gap is documented in the page itself rather than only in a tracker.

Also corrected: 09-rbac still granted FM "approve/reject redemptions" and listed approve in the role matrix. v3-82 removed manual approve for every role, so reject is the only redemption decision left in the panel. ⚠️ The Notion FM panel PRD (2026-06-18) says FM has no reject gate at all, matching neither the code nor this page; that is a product call, left unresolved rather than silently picked.

Docs: 14-decisions v3-99 · 07-redemption · 09-rbac. Notion "상환 출금 게이트 & Reject 재정비" handoff. Source: JY, 2026-07-30.

📅 July 29, 2026 — KYB dropped: the platform onboards individuals only (v3-98)

🚫 Entity onboarding is out of scope

Decision (v3-98): the platform onboards individuals only. KYB 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 marked superseded; its jurisdiction research stays in 03-kyc-identity as reference rather than a roadmap.

Why the distinction carries weight. "Deferred" and "dropped" look identical in a status column and mean opposite things downstream. Deferred makes every INSTITUTION branch scaffolding worth keeping warm and makes "coming soon" honest; dropped makes the same branches dead paths and the same sentence a promise that will not be kept. The docs had settled into the first reading — 03-kyc-identity said "not usable yet … planned August 2026 … KYB is a separate workstream, which ships first" — so a reader would reasonably conclude entity onboarding was on the way. That page now leads with the drop and explains how to read the retained research.

Kept on purpose: the kyc_level enum (INDIVIDUAL/INSTITUTION) and SUMSUB_LEVEL_MAP. The SBT stores a level, v3-63 already removed level-based pool gating, and removing the value would reach into the SBT contract — so the dormant branch is harmless. The entry states explicitly that its presence is not evidence the feature exists, because that inference is the mistake being guarded against.

🔴 Left open — investor-facing copy. KYCModal still renders "Institutional verification is coming soon" behind the VITE_KYB_ENABLED gate. With KYB dropped that is a commitment the product will not honour — the failure mode CLAUDE.md rule 2-b exists to prevent. Reword to "not supported", or drop the individual/institution selector entirely. Deliberately not decided here, and deliberately not silently edited: it is user-facing product copy, not a doc-accuracy fix.

Unaffected: the Reg S qualified-investor model (v3-74) was always individual-first and ships independently.

Docs: 14-decisions v3-98 · v3-75 (superseded) · 03-kyc-identity. Notion "KYB(법인 온보딩) — 현황 및 활성화 계획" (단계 = 드롭). Source: JY, 2026-07-29.

📅 July 29, 2026 — Doc-vs-code reconciliation: v3-69 fee split shipped, v3-86 archive is a cold table (not S3), 6 live columns were undocumented

Doc corrections only — no code change. A page-by-page audit against the repo found three places where the docs asserted the opposite of what the code does, plus five smaller drifts. Each was a status claim that outlived its implementation, and two of them contradicted another page on this same site.

  • v3-69 fee split — was "not yet implemented", is shipped. The badge read 🔧 Decided · on-chain pending and the body said "the 2-way split is not yet implemented (CH handoff)", as did 04-pool-models in two places. It is live end to end: PlatformPool.withdrawFees(stablecoin, treasuryAmount, poolMgmtAmount)YieldLib.withdrawFees debits unclaimedYield once and transfers each non-zero leg to treasuryWallet / fundFeeWallet in the same tx (reverting FundFeeWalletNotSet rather than burning to address(0)); migration 0067 added pools.fund_fee_wallet + yield_distributions.pool_mgmt_fee_amount; run-distribution.ts computes the amounts off-chain and yield-distributions.post.distribute passes poolMgmtAmount through. The decision title was also wrong — it said Pool mgmt routes to fund_wallet, the deposit money-path wallet, when the whole point of v3-69 is that it routes to a separate fund_fee_wallet. The 04 "Fee Destination" section additionally still described a single destination reachable only by "multi-sig + timelock", contradicting the mutability matrix on the same page — both treasury_wallet and fund_fee_wallet are instant DEFAULT_ADMIN_ROLE setters with no timelock (a delay cannot stop a payout to a wrong address, it only traps the fix).
  • v3-86 archive — S3 was the plan, a cold table is what shipped. The Result said "swap the archive scheduler from purge → S3 export then hot-delete" while 13-operations correctly described migration 0082: activity_events is append-only via trigger, and archive_expired_activity_events() relocates aged rows into the new activity_events_archive cold table (atomic INSERT + DELETE). S3 is a later enhancement. The same Result listed the BE writers and the FE tabs as pending; both are built (writers for all 4 gap categories + handler-enforced reason on impairment / freeze / qualification; Audit/Activity tabs with FM fund-scoping, outcome/severity filters, before→after detail). What is genuinely open is now named: the audit target renders as plain text with no entity deep-link, and only pools.worker.deploy writes outcome: 'failure', so failure-path coverage is thin.
  • 06-writedown-nav Step 1 — nominal reserve contradicted the callout above it. The worked example drew $1,000,000 × 0.10 from the reserve, while the A4 warning callout at the top of the same section says the reserve is the live depleting balance, not deposits × reserve_bps (v3-16). The example now states that reserve_bps is only the funding target, that the figure used is PlatformPool.reserveBalance() (mirrored to pools.reserve_balance), and that the two coincide here only because the pool is at launch. Step 3 gained the same treatment: the multiplicative chain and the A4 absolute recompute agree at NAV $1.00 and diverge afterwards — the absolute form is the live behaviour.

Also corrected in the same pass — five smaller drifts:

  • 11-db-schema was missing 6 columns that are live in production. epoch_schedule_type · funding_anchor_date · recall_lead_days · request_window_days · epoch_cycle_mode · next_funding_date were applied straight to Supabase main on 2026-07-27 for v3-93, but are absent from migrations/, from schema.sql, and from this page — whose stated purpose is to mirror the deployed schema. All six are now documented with the v3-93 semantics they implement, under a callout that spells out the three consequences: the repo cannot reproduce the live schema, column existence ≠ feature existence (the engine is still blocked on the contract-anchor redesign), and migration_history is not a reliable record — SQL-Editor applications change the schema silently, which is why the (pending apply) labels in this page's header are intent rather than verified state. Owner for the repo-side migration: CH.
  • 09-rbac listed 7 page keys; there are 9. It also named a key that does not exist (funds — the real one is fund_managers) and omitted activity and notifications, so the FM's fund-scoped Activity access and the Operator notifications gate (S-16) were both invisible. Now enumerated from ALL_PAGE_KEYS with each role's defaults.
  • 15-api-reference undercounted by 17 handlers — "116 handlers / 132 endpoints" against an actual 133 endpoint handlers + 13 schedulers + 3 workers serving 136 routes.
  • 21-holder-verification had two settled decisions still open. The 5-state model shipped (resolveHolderState) and the portfolio_positions.source value list is a live CHECK (0014). Ticking the second one carries an explicit warning, because settling the value list is exactly what gets mistaken for building the producers — only TRANSFER_IN is written.
  • 04-pool-models said operating_currency had "API handling pending" — it is accepted on create/update and gates tranche-group membership. fx_rate_source is enum-validated; the genuinely missing guard (reject a non-USD operating currency with no fx_rate_source) is now named as such instead of hiding behind a vague "pending".

Why this class of error recurs: a status claim is written once, when the decision is made, and nothing forces it back open when the implementation lands under a different commit. Both directions occur — docs claiming "pending" for shipped work (v3-69), and docs omitting shipped work entirely (the 6 columns). The cheap self-check: for any pending / not yet implemented claim, grep the named symbol before trusting it; and for any "reflects the deployed schema" claim, remember that the deployed schema has two write paths and only one leaves a trace.

Not touched here (owned elsewhere): notification behaviour docs (22-notifications, the 13-operations event matrix) ship with the in-flight notification PR per the Notion 정리본 + code handoff — including the producer-side gap where 8 legacy events bypassed copy.ts and rendered broken in-app cards, whose fix (P0-A) is landing in that branch.

Docs: 11-db-schema · 09-rbac · 15-api-reference · 21-holder-verification · 14-decisions v3-69 · v3-70 · v3-86 · 04-pool-models · 06-writedown-nav. Source: JY, 2026-07-29.

📅 July 28, 2026 — Freeze milestones get notified: dates up front and two resumption notices; notification inventory re-verified (v3-97)

🧊 The two moments nothing announced

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 freeze lapses at 7 days, both computed on read. So the platform is the only possible source of the notice, and it sent none: pool_unfrozen is queued only from EmergencyUnfrozen, which auto-expiry never fires. pool_frozen meanwhile promised "we will notify you as soon as activity resumes" with no producer behind it, and said deposits and withdrawals are paused without mentioning that withdrawals reopen at 72 h. Decision (v3-97): dates up front and two resumption notices — not either/or. (1) pool_frozen / freeze_extended carry withdrawalsResumeAt + haltLiftsBy instead of a promise, matching what the FE banner has done since v3-95. (2) New freeze_exit_window_open at +72 h — stating an expected time does not discharge the duty to confirm resumption, and 72 h is the moment an investor can act on their money. (3) New freeze_expired at +7 d, kept distinct from pool_unfrozen on purpose: auto-expiry is the design working, not an operator judgement, so the copy reads "lifted automatically" and the log keeps the provenances apart. (4) Dedupe keyed to the freeze cycle(event_type, pool, created_at >= freeze_started_at), the partner-funding pattern; not dedupByEntity, which is one-per-pool-forever and would silence every freeze after the first. An extension re-bases freeze_started_at, so the key re-arms both notices, which is correct because an extension restarts the 72 h block. (5) Freeze copy needs a datetime formatter with an explicit UTC labelformatDate() truncates to YYYY-MM-DD, which cannot express a 72 h milestone. Rejected: channel split (in-app-only needs channel-filter infra and would route the most consequential notice through the weakest channel). Both new events ship with the auto-expiry DB fix (CH) — nothing today knows the 7-day mark passed, and the sweep must also inspect still-frozen pools for the 72 h mark. Also found: freeze_extended interpolates the new freeze start into a row labelled Until, so extension emails show today's date as the end of the freeze.

Notification inventory re-verified against copy.ts (38 entries). 22-notifications was missing seven events that do have producersredemption_escalated, yield_distribution_due / _overdue / _escalation, kyc_reverify_due, sbt_mint_failed_user, nav_proposal_pending — plus lp_mint_failed (copy-only, absent from both the inventory and the deferred list). Send-worker cadence corrected (3 min, not ~1 min). large_deposit clarified: the string in admin-notification-preferences is an admin preference category, not a producer, so "not produced" still holds.

Docs: 14-decisions v3-97, 22-notifications; acts on the open item in v3-96. Notion "Freeze auto-expiry" BE handoff.

📅 July 27, 2026 — Recording yield = distributing it; status-scoped recovery; distribute tx_hash persisted before finalize (v3-96)

💸 One-shot distribute, safe recovery, no orphaned PENDING

The admin Yield page framed Record and Distribute as two steps with a "Ready to distribute" queue of PENDING records — but the backend already distributes in one request. yield-distributions.post.create runs the full distribution inline: the server path (deposit:false) inserts then calls runYieldDistribution and returns DISTRIBUTED (or FAILED); the FM path records PROCESSING with the client deposit_tx_hash, and POST /{id}/distribute verifies that deposit landed on-chain and finishes. No async worker processes PENDING rows, so a lingering PENDING is not a "waiting" state — it is a distribution that started and never finished. Decisions: (1) recording yield is distributing it — one action, labelled "Distribute"; fees are never a prerequisite (a null net_yield_fee_config = 100% of gross to investors, and fee rates are editable at any lifecycle stage per v3-69). (2) Recovery is scoped to what the endpoint safely supports, because a blind retry can double-distribute: /{id}/distribute accepts PROCESSING only (idempotent once DISTRIBUTED) → PROCESSING → "Distribute" (finish); FAILED → "Re-distribute" opening a fresh record (the failed attempt never distributed, so re-creating is safe); PENDING → no self-serve action (a finalize-DB orphan that may already have distributed on-chain — retrying it risks paying twice; surfaced read-only). (3) Root-cause fix (apps/infra): runYieldDistribution now writes tx_hash + tx_submitted_at immediately after distributeYield succeeds on-chain, before the 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 with no tx_hash — and the indexer (writeYieldDistributed) reconciles by tx_hash, so it could never heal the row. With tx_hash written early, the indexer reconciles any such row to DISTRIBUTED; no orphan lingers and no retry can double-pay. On-chain calls and amounts are unchanged. Admin UI also: record-modal gate is wallet-only (fees no longer block); a Calculation section itemises Pool TVL · APY · Yield period → Estimated yield, then Gross → Fee (subtitle + per-leg breakdown, live link to the pool's yield-fees step, "none" when unset) → Net to investors; Overview leads with estimated yield across overdue pools and the all-distributions total uses counts.ALL. tsc -b green (admin + infra). Infra change is deploy-pending (no auto-deploy).

Docs: 14-decisions v3-96, 04-pool-models → Yield, 23-money-path.

📅 July 27, 2026 — Copy gets the failure signal it never had: copy modules + build-gating guard + CLAUDE.md rule 2-b (v3-96)

🚧 v3-95 fixed the strings; this fixes the mechanism

v3-95 corrected the wrong copy and wrote down the right copy — but nothing stopped the next unverified line from shipping identically. The mechanism is worth naming precisely, because the obvious diagnoses are both wrong: it is not carelessness and not a review gap. Copy is the only surface here 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 throughout. Worse, the failure mode is adversarial to review: give a component a text slot and no spec, and what 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 this FE was ported from mockups, so realistic fake content arrived with nothing marking it as fake. Root cause: the absence of a spec is invisible when copy is written and expensive when it is reviewed. Three mechanisms, each making that absence visible. (1) Copy modules — status copy moves out of components into apps/{web,admin-web}/app/shared/copy/status.ts, so the surface is auditable in one file, a copy change reads as a copy diff rather than hiding in JSX, and each string carries the code path enforcing its claim (a reviewer follows the citation instead of reconstructing behaviour; an author who cannot produce one knows they are guessing). (2) scripts/check-copy.mjs gates the build — bans affirmative guarantee/protection language, invented deadlines and weekday cadences, and any clock read inside a copy module. Deliberately a build step, not a lint rule: CI runs build, not lint, and pnpm lint currently fails in both apps on ~65 pre-existing errors, so an eslint-only gate would gate nothing. Negated disclaimers pass by design ("returns are not guaranteed" is required copy), which needed a negator lookback rather than adjacency — adjacency broke on "not a guaranteed amount", a real line in epoch-countdown.tsx that the first draft flagged. Verified with 10 regression cases: all 8 known-bad v3-95 strings fire, both negated forms pass. Escape hatch copy-guard-allow: <reason>, reason mandatory. (3) CLAUDE.md rule 2-b extends rule #2 from buttons to text — unverified behavioural copy must be TODO(copy) + a surfaced question, never plausible text; framed as an extension of an accepted norm so it sticks. Also mirrored as eslint no-restricted-syntax over app/shared/copy/** for editor feedback. Not adopted now: adding lint to CI (blocked on the 65-error backlog) and mock-content sentinels (belongs to the mockup workflow). tsc -b green both apps; copy-guard clean over 331 files; full build:dev green with the gate active. No schema/BE/contract change. Open: confirm dialogs, notification email copy (lib/shared/notifications/copy.ts), disabled tooltips and empty states are unaudited — notification copy is the highest-consequence since it leaves the platform and cannot be recalled.

Docs: 14-decisions v3-96, 10-status-machines → Status Banner Copy; makes v3-95 enforceable.

📅 July 27, 2026 — Status banner copy is a correctness surface: SoT table + five rules; fabricated investor drawer removed (v3-95)

🗣️ Copy that made claims the code does not

Auditing the v3-94 banner surfaced the same defect class across every status surface. (1) 🔴 The investor paused-drawer was fabricated end to end (web/shared/ui/PoolStatusAlert.tsx): a "Paused: {date}" line computed from Date.now() at mount (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 claiming principal is "secured in underlying assets" (pools may be UNSECURED), yield "continues to accrue during review" (accrual is MANUAL_CLAIM — there is none), and "withdrawals resume when pool reopens" (they never stopped) — plus two dead buttons (CLAUDE.md rule #2). None of it came from data; all of it reads as commitment. (2) 🔴 The paused banner inverted the flag: "Your investment is protected. Withdrawals resume when pool reopens."is_paused blocks capital in only, and redemptions process normally under a soft pause. (3) The frozen banner read as open-ended ("please check back later") though a freeze is asymmetric and self-expiring (v3-28) and both milestone columns were already on the read model, unused. (4) is_paused also blocks reinvest, which no admin copy mentioned. Decision: status copy is a correctness surface, not decoration — a wrong status line is the same defect class as a wrong nav_per_token. 10-status-machines → Status Banner Copy is now the SoT table for both apps under five rules: never call a state automatic unless a scheduler writes it · no protection/guarantee/recovery language · no invented dates or cadences (a Date.now() date is a fabrication, not a default) · name the axis (capital in vs value out) · every affordance must work. Shipped: truthful paused copy; frozen now states the real reopen + expiry timestamps; the drawer rebuilt as what is paused · what continues · good to know with a real support mailto:; admin banner → "Deposits are paused on this pool. Redemptions, withdrawals and yield claims continue." and "Resume Pool" → "Resume Deposits"; Deposit Pause control now says capital in — deposits and reinvestment. Docs: capability-matrix Paused/Frozen rows corrected + new Frozen is asymmetric and self-expiring subsection. tsc -b green both apps; no schema/BE/contract change. Open: wind-down "reserve" wording left matching the contract on purpose (see decision).

Docs: 14-decisions v3-95, 10-status-machines; implements the copy half of v3-94.

📅 July 27, 2026 — Pause is manual-only; overdue never auto-pauses. Auto-pause needs pause provenance first (v3-94)

⏸️ The admin UI announced a safety net that does not exist

The pool-detail banner read "This pool is automatically paused due to overdue yield distribution" whenever is_paused && yield_overdue. An audit of every write path found no such mechanism: is_paused = true is written only by pools.post.pause (ADMIN/OPERATOR endpoint) and pools.post.create; on-chain pause() is onlyRole(PAUSER_ROLE) whenActive whenNotFrozen, human-signed only; pools.scheduler.yield-due merely recomputes next_yield_due, sets the yield_overdue boolean and queues notifications (due once on transition, overdue after a 3-day grace, re-sent ≤ every 7 days), and every consumer of that flag is a badge or a filter. Redemption has no overdue concept at allepoch-execute triggers settlement + anomaly notifications, partner-funding sends reminders, neither judges lateness. The system's only genuinely automatic halt is the NAV staleness/deviation hold inside executeEpoch (v3-32), which blocks settlement, not deposits. So the banner fired on a coincidence and described it as cause and effect — and read as a safety net, it invites an operator to not pause a pool that needs it. Decided (as-is): pause is manual-only and no status copy may say otherwise (fixed in v3-95); the overdue signal stays notification + ops flag. Decided (prerequisite): auto-pause cannot be built on today's schema — is_paused is a bare boolean with no provenance, which forces two unresolvable collisions: resume ambiguity (auto-resume on distribution silently reverts a human pause set for an unrelated reason; manual resume leaves the FM waiting with no recorded reason) and escalation re-entry (v3-78/v3-92 auto-clear the flag on escalation, so a sweep would instantly re-pause a pool an operator just deliberately reopened). pause_reason + paused_by are a hard prerequisite. Six open questions are recorded with recommendations — chief among them that yield_overdue is not a sound predicate today (the anchor never rolls forward, so a pool awaiting its first distribution is permanently overdue — auto-pause would pause exactly the healthy new pools we want open), that the 3-day grace is a notification cadence and far too short to halt capital on, and that the answer is probably not a pause at all given notification escalation, the ops queue, fundingRestricted and IMPAIRED already cover the failure. Redemption-overdue becomes definable only after v3-93 supplies a per-cycle funding date to be late against. No schema/BE/scheduler/contract change.

Docs: 14-decisions v3-94, 10-status-machines → Capability matrix; constrained by v3-78 + v3-92; copy fix in v3-95.

📅 July 27, 2026 — Epoch request window is a hard gate; funding date is admin-entered per cycle; cancel window = request window (v3-93)

🚪 The freeze needs a gate that actually rejects, not just a cursor that moves

v3-91 specified Model B (a short requestWindowDays window, then a gap that accepts nothing) but described the freeze as the accepting/settlement cursor split — and a lone cursor split never rejects anything (acceptingEpochId is defined for every instant, so every request lands somewhere). requestWindowDays was therefore unenforced and the engine was still Model A. v3-93 amends it on four points. (1) The request window is a hard gate and is the freeze: requestRedemption reverts RequestWindowClosed outside [windowOpen(N), cutoff(N)) — only the lower bound needs a check, since N = floor((now − anchor)/duration) + 1 already implies now < cutoff(N). The cursor split stays but is demoted to a late-settlement safety net (cron delay leaves epoch N unsettled while the N+1 window opens). The window is load-bearing because it pairs with Rule 1 (yield stops at request): always-accepting means a request placed just after a cutoff waits duration + recallLeadDays (≈ 40 days monthly) earning nothing, versus the 14–17 days the v3-91 presets quote. (2) One reference point per cycle — the admin-entered funding date: fundingAnchor is not a month-end rule; the publisher reports its settlement date off-chain and the admin enters it each cycle (§3 semi-auto, so the FM wallet model is untouched — no on-chain date setter). cutoff(n) = fundingDate(n) − recallLeadDayswindowOpen(n) = cutoff(n) − requestWindowDays, with C2 fail-open defaulting to previous fundingDate + scheduleType period. So the contract never does calendar arithmetic (no date library, no pre-baked schedule array) and scheduleType shrinks to the fail-open step. Corrects two stale statements: the §3 table's "month-end / quarter-end" and v3-91's cutoff-anchored sketch. (3) The semi-auto funding-date knob is free only until that cycle's request window opens — which pins the §3 flow ("approve → window opens") rather than adding a rule. After the window opens a delayed cycle moves settlement only (standalone settleAfter[id], not an input to the cutoff derivation), and adjustment is delay-only. Blocks two failures: rewriting a deadline investors already see, and re-opening a closed window so the total already notified to the publisher changes. (4) Cancel window = request windowcancelRedemption carries the same gate, so a cancel can never falsify a notified total or break Σ claims = epochClaimable[id]; rolled-over demand is not trapped (a partially-filled investor cancels in the next window), and two griefing paths close for free. No BE change (on-chain cancel is verified then mirrored); the investor UI must state that escrow-period yield is not refunded. Contract impl pending (CH) — nothing on-chain changes yet. Source: JY review of the Notion handoff, 2026-07-27.

Docs: 14-decisions v3-93 (amends v3-91), 07-redemption. Notion "Epoch Redemption" handoff.

📅 July 27, 2026 — Status-flag auto-clear must also unpause on-chain (v3-92)

🔗 The is_paused auto-clear now reaches the contract, not just the DB column

Found on Test Pool 260616-base: admin pause returned 502 "On-chain pause operation failed" with no UI recovery. On-chain paused() was true while DB is_paused was false, so pause() reverted EnforcedPause(). Trail: PAUSE (07-03) → FREEZE (07-23) → UNFREEZE (07-27) with no UNPAUSE — the freeze's v3-78 auto-clear turned the DB column off without touching the contract, and OZ Pausable is independent of emergencyFreeze() / the lifecycle enum. Effect: deposits kept reverting whenNotPaused invisibly while the UI showed the pool open, and nothing self-heals it (the indexer does not mirror paused() back). Fix: pools.post.freeze (freeze) · .impairment (execute) · .wind-down (execute) now call the shared clearOnChainPause(poolAddress, chainId) (lib/shared/contract/pause.ts) — reads the live paused(), sends unpause() when set, after the escalation tx (unpausing first would leave the contract deposit-open if the escalation failed — the 08 §A gap; safe because unpause() is unguarded while pause() carries whenActive/whenNotFrozen). is_paused = false is written only if the contract is actually unpaused; a failed unpause keeps it true (audit pause_clear_failed, success records unpause_tx_hash) so the DB never claims a state the chain lacks. BE (apps/infra): tsc --noEmit green (deploy-pending — infra has no auto-deploy). Already-desynced pools need a one-off POST /pools/{id}/pause {"paused": false}.

Docs: 14-decisions v3-92 (amends v3-78), 10-status-machines → Auto-clear, 08-smart-contracts §A.

📅 July 24, 2026 — Epoch redemption engine redesign: calendar-anchor schedule, demand freeze, settlement escrow (double-commit fix), yield-stop-at-request (v3-91)

🔁 Epoch engine to be rebuilt as one unit — decided, contract impl pending (CH)

A line-by-line audit of RedemptionLib / YieldLib vs the epoch product intent surfaced four coupled gaps that only close together. v3-91 decides the target design (not yet implemented — current on-chain behaviour is unchanged): calendar-anchor scheduling (fixed fundingAnchor + scheduleType monthly/quarterly + requestWindowDays / recallLeadDays, replacing lazy-start + back-to-back; claim = fundingAnchor, processingDays 0, NAV approve pinned before funding); demand freeze at cutoff (accepting cursor split from the settlement cursor) so the confirmed total can be sent to the publisher and funding targets the frozen epoch; a recallLeadDays gate on executeEpoch; per-epoch settlement escrow (epochClaimable pot debited at settlement, claims draw only from it) fixing the cross-epoch double-commit and isolating yield / reserve / redemption balances; yield stops at request (remove lockedYield and exclude escrowed LP from the distribute denominator — as one set; reverses "method (b)"); carry-first FIFO (2-tier ladder, O(1)); and NAV propose/approve/override + a bug-guard NAV > 0 floor. 🔴A + 🔴B + 🔴G + freeze ship as one PR / audit unit (CH); C3+I and C8 layer on top. Non-contract follow-ups tracked per PRD (JY). Source: audit + design session, 2026-07-24.

Docs: 14-decisions v3-91, 07-redemption; revises v3-26, v3-18; interacts with v3-46. Notion "Epoch Redemption" handoff.

📅 July 24, 2026 — Joob fund-data semantics: NPL at DPD 90 / write-off ~180 DPD, cash-received fund value, EOD 09:00 refresh (v3-90)

🏷️ Joob confirms loss timing and fund-value basis

On the 2026-07-24 Joob call two long-open data-request items closed. A loan is NPL at DPD 90 but written off at ~6 months (≈180 DPD) — non-performing at 90 it stays on book (feeds npl_ratio); actual write-off into realized cumulative_loss (Joob's separate "written-off / in recovery" line) is at ~180. So npl_threshold_days = 90 and write_off_policy ≈ 180 do not collapse — there is a real 90–180 DPD NPL window and the risk badge's leading NPL signal fires normally. current_fund_value is cash-received: only interest actually received counts; accrued / 미수 (approved-but-uncollected) interest is excluded until it settles (current approved→received lag ≈ 1–2 days). Since that lag is ~one day of interest (immaterial vs NAV), Aset mirrors the cash fund_value as-is and drops the accrued_income request (0068 column stays dormant). Cadence is moving to an EOD daily 09:00 refresh (like DPD). Docs-only; no schema/contract change; the deferred automated DPD→NAV writedown (v3-13) still awaits per-loan data. Source: Joob call, 2026-07-24.

Docs: 14-decisions v3-90, 20-joob-pool-config, 06-writedown-nav; refines v3-72.

📅 July 23, 2026 — Redemption terminology: OPEN_ENDED canonical, "revolving" = UX nickname; open-ended pools have no early-exit penalty (v3-89)

🏷️ OPEN_ENDED is the term; open-ended = no penalty window

The 3-state lockup model used "REVOLVING" as if it were a maturity_model value (the enum is only FIXED_TERM / OPEN_ENDED; "REVOLVING" is UX copy the API rejects) and contradicted itself for open-ended pools — the EARLY row implied a penalty while the FREE row said free-right-after-lock-up. v3-89 settles both: an OPEN_ENDED pool has no EARLY window (LOCKED → FREE after lock-up), so an early-exit penalty never applies — EARLY/penalty exist only for FIXED_TERM. Matches the contract (maturityDate = 0 ⇒ not EARLY) and the wizard (penalty hidden without a maturity). Terminology normalized to OPEN_ENDED across 07-redemption and 04-pool-models; "revolving" stays only as a UX nickname; the "Epoch-Based Redemption (REVOLVING pools)" heading → "(Open-Ended Pools)" with its 10 cross-links updated. The LINE BK example (OPEN_ENDED + PRINCIPAL_BASED) is flagged inert. Docs-only. Source: JY, 2026-07-23.

Docs: 14-decisions v3-89, 07-redemption, 04-pool-models; clarifies v3-46.

📅 July 23, 2026 — Admin redemption-step wizard: consistency + config guards, settlement UX, redemption_type relabel (v3-88)

🔁 Redemption step: hide what doesn't apply, block dead-end configs, clearer labels

The admin pool create/edit Redemption & penalty step let users express meaningless or redemption-blocking configs and used labels that misread. v3-88 (audited against on-chain RedemptionLib, backend eligibility, and the specs): gating + the instant-only "Expected settlement" notice show only in their applicable mode (D1/D3); the penalty section is gated by maturity on edit too (D2); LIQUIDITY_WINDOWS is removed from the dropdown as an unwired redemption-blocking trap (D5 — enum kept on-chain, edit keeps a read-only legacy fallback); OPEN_ENDED + FIXED_MATURITY is blocked since it never reaches a maturity (D6); FIXED_MATURITY suppresses the epoch selector and forces epoch_duration_days = 0 — a matured pool pays out once (D7 — admin-UX guard only; the contract still permits the combo; does not change v3-46, so epoch stays orthogonal to maturity_model and FIXED_TERM + ON_DEMAND + epoch is still valid); the settlement field becomes an explicit Instant / Epoch mode select with a cycle-length sub-field (D8); and redemption_type is relabeled to what it controls — "Redeem at maturity only" / "Redeem on request" (D9). admin-web-only; no DB/schema/contract/BE change; tsc -b green. ⚠️ Follow-up (CH): FIXED_MATURITY with no maturity diverges — on-chain allows, backend blocks. Source: JY, 2026-07-23.

Docs: 14-decisions v3-88, 07-redemption, 04-pool-models; tightens v3-46 at the admin layer; Notion "Epoch config 정합성".

📅 July 22, 2026 — Pool-detail IA: Controls is actions-only, NAV trend → Overview chart, gov/lifecycle history → Audit Log (v3-87)

🎛️ Controls holds actions; read-only history moves out

The admin pool-detail Controls tab had mixed live actions, an inline NAV-change table, and a governance/lifecycle history feed under an inconsistent hierarchy. v3-87 makes Controls actions-only with one uniform 3-tier structure — group eyebrow → panel title → one-line subtitle — across Deposits, Danger zone, and Pool lifecycle (danger/lifecycle red, others neutral; colour signals meaning only). Each control is a compact row; the long body paragraphs were dropped since the confirm dialogs already carry them. Governance change and NAV change are actions that open weightier dialogs (timelock notice + current-values + primary propose + pending-proposal management) instead of always-expanded inline forms. The read-only NAV-per-token trend moves to Overview as a brand-coloured time-series chart with period tabs (1M/3M/6M/1Y/All); proposing/overriding NAV stays in Controls. Governance & Lifecycle history is removed from pool-detail — the global Audit Log (v3-86) is its single home. admin-web-only refactor: no BE/DB/schema/contract change; adds recharts (already used by web); deletes the nav-changes-panel / governance-history-panel widgets. tsc -b green. Source: JY, 2026-07-22.

Docs: 14-decisions v3-87; builds on v3-86.

📅 July 21, 2026 — Audit Log re-planned: audit-first SoT, snapshot actor, expanded coverage, Audit/Activity split (v3-86, extends v3-41)

🗂️ The audit log is now an audit record (SoT) with a read-view Activity feed on top — not a loose merged feed

The log was assembled piecemeal on v3-41 without deciding what it is, so actor_id was stored but never resolved (UI showed UUID / "Unknown" / "SYSTEM"; weekly 2026-07-20: "Actor ID → user name + role"). v3-86 re-scopes it across 6 layers: identity = audit SoT + Activity read-view; write scope = human discretionary acts only (economic / on-chain stay read-view UNION), adding the missing pool/tranche create·deploy·publish·update·delete, fund CRUD/member, admin-user CRUD/permission/wallet, investor qualification writers; actor = hybrid, human stream snapshots actor_name+actor_role at write time (join fallback), display name · role-badge; schema = before→after diff + reason (high-risk only) + outcome (failures logged); IA = Audit/Activity two tabs, FM sees own-fund Activity only; retention = DB-level append-only + 5y then cold-archive to S3 (replaces hard-purge). Source: JY, 2026-07-20 weekly + 2026-07-21.

Docs: 14-decisions v3-86, 13-operations → Audit Log Retention & Export; extends v3-41; schema change pends 11-db-schema; Notion "Admin-web Audit Log".

📅 July 21, 2026 — Early-exit penalty destination: fund_wallet, not Pool reserve (v3-85)

💸 Early-exit penalties go to the fund manager's wallet, not the pool reserve

v3-84 sent the early-exit penalty to reserveBalance; v3-85 routes it to the pool's fund_wallet instead — across all penalty types (PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED, instant + epoch). The amount/formula is unchanged; only the destination moves. ⚠️ Note the real economics: steady-state navPerToken is oracle-set and independent of reserveBalance (reserve feeds NAV only in WIND_DOWN, where nav = reserveBalance / totalSupply), so keeping the penalty in reserve never raised remaining LPs' day-to-day NAV — it fed the pool's redemption liquidity buffer and the wind-down pro-rata recovery. Moving it to fund_wallet removes both and hands the penalty to the FM. Contract (RedemptionLib.sol): replace s.reserveBalance += penalty at all 5 credit sites with safeTransfer(s.fundWallet, _denormalize(penalty)) + draw the penalty down from reserve / totalDeposited (draw the full gross, not just the net payout, or reserveBalance drifts above physical USDC). Bundled with the v3-84 YIELD_BASED payout = principal − penalty change. [User/CH] contract redeploy required (new pools) — the deployed contract keeps the reserve behavior until then. Source: JY, Redemption Flow QA.

Docs: 14-decisions v3-85, 07-redemption → Penalty Types; supersedes the penalty destination in v3-84; CLAUDE.md Penalty Types.

📅 July 20, 2026 — YIELD_BASED penalty reinstated with lock-up gating (v3-84, reverses v3-79 deprecation)

🔁 YIELD_BASED is a valid penalty again — for lock-up pools whose lock-up releases only after the first yield distribution

v3-79 deprecated YIELD_BASED because a "% of accrued yield" penalty is 0 when no yield has accrued — toothless in the EARLY window it should guard. v3-84 reinstates it behind two guardrails that remove that failure mode: it is selectable only on lock-up pools (lockup_days > 0), and the lock-up cannot release before the pool's first yield distribution — so an investor never reaches EARLY with zero accrued yield. Mechanics: penalty = accrued_yield × penalty_rate_bps / 10000 → Pool reserve. PRINCIPAL_BASED stays the default; the v3-79 partial-vs-full policy is unchanged. This also un-orphans the Joob pool (SSA §4.2 = 50% dividend forfeiture = YIELD_BASED 5000 bps), which v3-79 had contradicted. Deduction target (decided 2026-07-21): from principalpayout = redeemed_principal − penalty, penalty → reserve (⚠️ destination later moved to fund_wallet by v3-85); already-distributed yield can't be clawed back after the fact. This required a contract change + redeploy, since shipped (v3-84/v3-85): the deployed RedemptionLib computes payout = redeemed_principal − penalty and routes the penalty to fund_wallet. BE re-allows YIELD_BASED only with a lock-up + enforces lock-up-release ≥ first yield distribution; FE re-adds the create option (shown once a lock-up is set). Source: JY, Redemption Flow QA.

Docs: 14-decisions v3-84, 07-redemption → Penalty Types, 20-joob-pool-config; reverses the deprecation in v3-79; CLAUDE.md Penalty Types.

📅 July 20, 2026 — Lock-up decoupled from penalty type (v3-83, supersedes v3-66)

🔓 A lock-up no longer forces a penalty — NO_EARLY + lock-up is now valid

v3-66 defined NO_EARLY as "free exit from t0", coupling it to lockup_days = 0 and ON_DEMAND and enforcing that on-chain. But lock-up (the LOCKED window, where redemption is blocked entirely) and the early-exit penalty (the fee in the EARLY window, after lock-up, before maturity) are independent — "locked 30 days, then penalty-free redemption" is a normal product. v3-66 also broke the create wizard: Step 3 demanded a penalty "in Step 5", but Step 5 hid the penalty selector without a maturity, so the requirement was unsatisfiable. v3-83 makes the two independent: NO_EARLY = the early-exit penalty is 0, nothing more. Removed both on-chain guards (GovernanceLib.setLockupDaysNoEarlyRequiresZeroLockup; PoolConfigLibNO_EARLY ⇒ ON_DEMAND) and the now-unused error; relaxed backend pools.post.create / pools.patch.update; dropped the FE create-wizard coupling + the NO_EARLYON_DEMAND forcing; investor web now reads the real lockup_days for every penalty type and gates redemption purely on LOCKED. Foundry 307 tests pass (revert-tests converted, lock-up+NO_EARLY redemption asserted); ABI regenerated (error-only); tsc -b green (web + admin-web). [User] contract redeploy required (new pools only; the v3-66 guard was still pending deploy).

Docs: 14-decisions v3-83, 07-redemption → 3-State Lockup, 04-pool-models → penalty_type, 24-field-governance; CLAUDE.md Penalty Types; supersedes v3-66.

📅 July 20, 2026 — Partner funding auto-settles a PENDING_RESERVE redemption (v3-82)

⚡ FM tops up the reserve → the pending redemption auto-settles, no admin approve

Reverses the v3-76 sub-decision that gated a funded shortfall on an admin approveRedemption. In the instant path the reserve check still happens at request time (covered → settle immediately; shortfall → escrow LP + PENDING_RESERVE + fm_shortfall alert). Now the partner's fundRedemption auto-settles the request the instant the pool balance covers the payout — burn LP + pay the investor + COMPLETED, same call, no admin step. A still-short top-up stays PENDING_RESERVE for a follow-up fund. Full auto — no compliance/anomaly hold on the instant path. approveRedemption survives only as an optional manual settle for a reserve that grows independently before the permissionless claimRedemptionFallback notice. The backend (fund.ts + fundRedemptionOnChain) was already written for auto-completion — v3-76 changed only the contract, so this restores BE ↔ contract consistency. Contract RedemptionLib.fundRedemption updated; Foundry 307 tests pass; PlatformPool 19,480 / 24,576 (headroom unchanged). [User] contract redeploy required (new pools only). No DB/ABI change.

Docs: 14-decisions v3-82, 07-redemption → Instant Redemption Flow; reverses v3-76.

📅 July 20, 2026 — Pool status flag auto-clear + wind-down no-freeze (v3-78)

🔀 One effective status: higher state auto-clears lower flags; wind-down stops freezing

The two-axis model (lifecycle enum + is_paused / is_emergency_frozen flags) stays, but flags now behave as if exclusive. Admin actions auto-clear 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; pause is only accepted on an ACTIVE, non-frozen pool. Most importantly, executeWindDown no longer sets is_emergency_frozen = true (Option A) — because freeze outranks WIND_DOWN in priority, a wound-down pool used to display "Frozen (all halted)" and hid its pro-rata redemption path, contradicting wind-down's purpose. On-chain requestRedemption has no whenNotFrozen guard and RedemptionLib permits wind-down redemption, so redemption always worked — only the DB flag + FE display were wrong. BE (apps/infra/lambda): pools.post.wind-down / .freeze / .impairment / .pause updated, tsc -b green (deploy-pending — infra has no auto-deploy). Admin pool-controls redesign (single status indicator, safe→irreversible CTA order, higher-state-disables-lower) handed off to the admin-web session.

Docs: 14-decisions v3-78, 10-status-machines → Auto-clear; CLAUDE.md Pool 복합 상태.

📅 July 20, 2026 — Early-exit penalty → PRINCIPAL_BASED + partial-redemption policy (v3-79)

💸 Penalty on redeemed principal, not yield; YIELD_BASED deprecated; terminal states full-only

The YIELD_BASED deprecation in this entry was reversed by v3-84 (2026-07-20) — reinstated for lock-up pools (lock-up release gated to first yield distribution). The PRINCIPAL_BASED default + partial-vs-full policy below still stand.

PRINCIPAL_BASED (% of the redeemed principal) becomes the default early-exit penalty and YIELD_BASED is deprecated (unselectable at creation; existing pools migrate to PRINCIPAL_BASED). 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 — and benchmark research found no real precedent (CDs forfeit contractual interest and can bite principal; tokenized RWA funds charge a flat % of principal, or nothing). Partial redemption stays allowed in ACTIVE / IMPAIRED (penalty pro-rated on the redeemed slice only); MATURED / WIND_DOWN are full-only. No contract change, no column deletion: the contract already computes PRINCIPAL_BASED as grossAmount × bps / 10000 where grossAmount is the redeemed slice (so partial pro-rata is automatic, identical for instant + epoch); YIELD_BASED remains a dead on-chain branch. Pending handoff: FE removes the YIELD_BASED create option; BE data-migrates existing YIELD_BASED pools (⚠️ rate re-set required — bps meant "% of yield", now means "% of principal"); BE rejects partial in terminal states (on-chain FullRedemptionRequired guard deferred to next redeploy — not a safety issue).

Docs: 14-decisions v3-79, 07-redemption → Penalty Types / Partial vs Full; CLAUDE.md Penalty Types.

📅 July 20, 2026 — Rule #7 revision: admin inputs accept percent (v3-80)

📊 Admin create/edit ratio fields typed as %, converted one-way to bps at save

Revises the v3-73 carve-out that had kept admin input in bps. The admin create/edit form now accepts reserve_bps / redemption_gating_bps / penalty_rate_bps as percent — operators type 2.5, the form converts Math.round(pct × 100) → bps at save. Storage, API payloads, Lambda, and the contract stay bps (unchanged). Safe because it's a single one-way conversion at the input boundary (not the bidirectional bridge rule #7 bans) and the round-trip is lossless (percent ≤ 2 dp ↔ integer bps, 1 bps = 0.01%). Validation: admin input uses isPercent (0–100, 2 dp) then converts; all other paths keep isBps. Pending handoff: admin-web form fields → suffix="%" + = X bps hint; payload sends converted bps.

Docs: 14-decisions v3-80, 14-decisions v3-73; CLAUDE.md rule #7.

📅 July 20, 2026 — Create validation + REVOLVING / APY display (v3-81)

📅 maturity > lockup, REVOLVING publish exemption, per-year APY for open-ended pools

Three create/display fixes. Maturity must be strictly greater than lockup (>, was ) so the EARLY window can't be zero-length; lockup_days = 0 and maturity_days = 0 (REVOLVING) boundaries unchanged. REVOLVING pools are exempt from the publish end_date requirement (publish always requires start_date, end_date/maturity only for fixed-term). Investor FE shows APY per-year for open-ended pools: APY is annual, so fixed-term pools pro-rate it across the remaining term ("Est. value at maturity"), while REVOLVING pools now show ~$X / year instead of a full-year lump mislabeled "at maturity" (the old "$108" over-promise). Done: investor web invest-sidebar.tsx, tsc -b green. Pending handoff: FE create validation (3 sites) + BE maturity guard + BE publish gate.

Docs: 14-decisions v3-81, 07-redemption → 3-State Lockup.

📅 July 16, 2026 — Design system consolidated into shared @aset/ds-editorial (v3-77)

🎨 One SoT for fonts + palette + type scale + patterns (web + admin)

@aset/ds-editorial now owns the whole frontend design system: font-family tokens + @import loading (fonts.css), the full color palette + shadcn semantic --color-* + type scale (tokens.css), the five pattern components (patterns.tsx), and a living /styleguide showcase (showcase.tsx). Per-app global.css no longer redefines palette/fonts — web dropped its copy; admin imports tokens.css after finance-dashboard-kit so the Aset palette overrides the kit. Result: web and admin render one identical palette, and changing a color or font is a one-file edit. /styleguide is mounted in both apps (outside auth) and renders the real tokens + components, replacing the earlier hand-built copies — the claude.ai artifact and Drive color HTML — now deprecated. Both apps build; both bundles load Quicksand + DM Serif Display and resolve brand #5423e7.

Docs: 25-design-system, 14-decisions v3-77.

📅 July 16, 2026 — Redemption Flow overhaul: reserve-gated instant settle + lockup hard-block (v3-76)

⚡ Instant settle when reserve covers; escrow + FM-fund + admin-approve on shortfall

requestRedemption (instant pools) now settles in the same tx when reserveBalance ≥ payout — LP burned directly from the investor, USDC paid out, status COMPLETED, no escrow and no separate approve. On a shortfall it escrows the LP into PENDING_RESERVE, alerts the fund (fm_shortfall) at request time; the partner tops up via fundRedemption (no longer auto-completes) and an admin approveRedemption releases the funded payout. cancelRedemption on PENDING_RESERVE refunds partner funding. Since deposits route 90% to fund_wallet (10% reserve), only small redemptions settle instantly.

🔒 Lockup is a hard block (model X) — LOCKED = cannot redeem, on-chain + BE + FE

Corrects a contract/docs mismatch: requestRedemption previously allowed redemption during lockup with a penalty (isLockupActive || isBeforeMaturity), contradicting the 07-redemption 3-State Lockup model where LOCKED = cannot redeem. Now: during LOCKED (now < invested_at + lockup_days) requestRedemption reverts RedemptionNotAllowed; create.ts and both FE redeem paths (portfolio inline + RedeemModal) block too. The per-penalty_type fee applies only in the EARLY window (lockup_end ≤ now < maturity). Wind-down waives lockup. Escrowed LP is shown "🔒 locked in redemption" in the portfolio. ⚠️ PlatformPool is clone-based → applies to newly deployed pools. 306 contract tests green; CLAUDE.md Penalty Types table + 12-triggers corrected.

Docs: 14-decisions v3-76, 07-redemption; CLAUDE.md Penalty Types.

📅 July 16, 2026 — NO_EARLY ⇔ lockup_days=0 enforced on-chain (v3-66)

🔒 setLockupDays rejects NO_EARLY + positive lockup

NO_EARLY promises penalty-free exit from t0, which by definition means no lockup (lockup_days = 0, v3-66) — a lock-up only applies with a real penalty type (FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED). The backend already rejected NO_EARLY + lockup_days > 0 (pools.post.create), but the on-chain setter did not, so a direct factory config call could deploy a pool that violated the rule. GovernanceLib.setLockupDays now reverts NoEarlyRequiresZeroLockup when newLockupDays > 0 and penaltyType == NO_EARLY, making the invariant hold on-chain regardless of caller (defense-in-depth). Guard lives in the delegatecall library, so PlatformPool bytecode size is unchanged (19,480 B); error mirrored on the pool; ABI regenerated (error-only, no selector/signature change). ⚠️ Clone-based (non-upgradeable) → applies to newly deployed pools. 304 contract tests green.

Docs: 24-field-governance §5; CLAUDE.md NO_EARLY rule (v3-66).

📅 July 16, 2026 — yield fee rates unified to basis points (migration 0075)

💰 net_yield_fee_config → bps

The pools.net_yield_fee_config JSONB rate keys — the last percent holdouts after the 0068 ratio→bps unification — moved to basis points: platform_yield_take_pct / spc_mgmt_pct / pool_mgmt_pct / perf_fee_pct / perf_hurdle_pct*_bps (×100; 1% = 100 bps). Migration 0075 rewrites the JSONB keys/values in place. Compute (run-distribution.ts) changes ×pct/100×bps/10000; the perf hurdle now compares apy_rate×100 > perf_hurdle_bps (apy_rate stays a percent column). Validation (pools.post.create / patch.update) is integer 0..10000. FE (web + admin-web) stores/inputs bps and displays via formatBpsAsPercent, mirroring reserve/penalty/gating. No on-chain change — the contract stores no fee rate (0067). Reverses the v3-73 carve-out that had left fee rates as percent.

Docs: 24-field-governance §3, 14-decisions v3-73; CLAUDE.md rule #7 updated.

📅 July 15, 2026 — treasury_wallet timelock removed → instant setter (implements v3-69)

💰 treasury_wallet → instant setter

treasury_wallet (Aset fee destination) changes are now applied by an instant admin setter setTreasuryWallet (DEFAULT_ADMIN_ROLE); the 7-day propose→execute→cancel timelock is removed, implementing the v3-69 decision that fee-side wallet changes apply immediately — fees auto-distribute, so a hold would only strand fees at a stale address, never prevent a bad payout. Mirrors the existing setFundFeeWallet. Contract: PlatformPool.setTreasuryWallet + GovernanceLib.setTreasuryWallet; the propose/execute/cancel functions, TreasuryChangeProposed/Cancelled events, and pending-treasury storage are removed; ABI regenerated. API: POST /pools/{id}/governance applies TREASURY instantly (no pool_governance_changes row, no execute/cancel). ⚠️ PlatformPool is clone-based (non-upgradeable), so this applies to newly deployed pools — existing pools keep their 7-day treasury timelock.

Docs: 24-field-governance §1, 14-decisions v3-69.

📅 July 14, 2026 — KYB (institution) onboarding policy confirmed (v3-75)

🏢 v3-75 — KYB institution onboarding policy

A multi-jurisdiction legal benchmark (SG·HK·JP·EU·UK·CH·UAE·TH·MY·ID; FATF + SumSub + per-country regulators) finalized the KYB (legal-entity/institution) onboarding policy. KYB is scaffold-only / not production-ready — the kyc_level enum + SumSub KYB level map exist, but FE institution submission is behind VITE_KYB_ENABLED (off; SumSub Enterprise KYB subscription planned August 2026), and UBO capture + admin institution-review are unbuilt. It runs as a separate workstream from v3-74 (individual Reg S gating ships first). Confirmed policy: target jurisdictions = same KYC/Reg S allowlist (no separate list; ID = separate OJK-sandbox track); entity eligibility = regulated/institutional auto-PROFESSIONAL else "large-undertaking 2-of-3" (20M/40M/2M, currency per jurisdiction; ADGM own-funds $1M); UBO = resolve to natural persons, collect ≥20% ownership/voting + control (20% = MY floor, covers 25% jurisdictions), determine per-jurisdiction — standard 25% + control + senior-official fallback, Japan cascade (>50% sole / >25% / control / rep-director), Malaysia 20% — as a per-jurisdiction/risk config; documents = standard set + jurisdiction extras (SG Bizfile, HK NAR1, JP registered-matters); representative = non-director signer needs board resolution/PoA; SumSub absorbs collection/UBO-mapping/screening but final accept/reject + liability stay with Aset. Threshold values + final launch countries + "are we a reporting institution" + ID DFA-vs-security remain BD/legal sign-off (values ship as placeholders). Policy + benchmark only — no code/schema change in this entry.

Docs: 14-decisions v3-75, 03-kyc-identity → KYB; Notion "KYB 관할권 벤치마크 & 결정" / "KYB 활성화" / KYC/KYB 기획서.

📅 July 14, 2026 — investor_tier → investor_status + eligibility_mode (v3-74)

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

The dormant US-centric investor_tier enum (ACCREDITED/QP) is replaced by a jurisdiction-based model implemented backend-side (no on-chain change). Migration 0072 drops investor_tier from users + pools; adds users.investor_status (RETAIL/PROFESSIONAL) + qualification_country/qualification_basis/qualification_verified_by/_at/_reason/qualification_expires_at (Status Gate state + audit) and pools.eligibility_mode (STATUS Status Gate / MIN_TICKET Ticket Gate). Existing rows default to RETAIL / MIN_TICKET (least-restrictive). checkKycGating now branches on mode — STATUS requires investor_status=PROFESSIONAL in an allowed jurisdiction (with qualification_expires_at currency), MIN_TICKET enforces amount ≥ min_investment — and returns machine-readable reason codes (NOT_KYC, KYC_EXPIRED, JURISDICTION_BLOCKED, US_PERSON, NOT_PROFESSIONAL, BELOW_MIN_TICKET). The min-investment floor moved from assessDepositEligibility into the gate (single check, surfaced as BELOW_MIN_TICKET). New admin endpoint PATCH /users/{id}/qualification grants/revokes PROFESSIONAL + records the audit trail. Country policy thresholds live in a scaffold config (country-eligibility-policy.ts) — placeholder values pending BD/legal sign-off. FE (web + admin-web) updated to the new field names; pool filter + settings + invest sidebar reframed to Status/Ticket. Sumsub auto-population of investor_status is a 2nd-phase item (v1 = admin manual).

Docs: 14-decisions v3-74, 11-db-schema, 03-kyc-identity, 04-pool-models.

📅 July 13, 2026 — FE shows ratio config as percent; storage stays bps (v3-73)

🔢 v3-73 — FE percent display (storage stays bps)

Rule #7 kept every ratio value in bps end-to-end (incl. FE) with no percent↔bps bridge. We now show investors percent instead of "1000 bps": FE renders reserve_bps / redemption_gating_bps / penalty_rate_bps as percent for display only via a one-way formatBpsAsPercent() helper (1000 → "10%", 250 → "2.5%"). Storage, input, API, Lambda, and the contract stay bps — no migration, no ABI change, no calculation touched. Admin operator input stays bps (numeric + isBps); create/edit gains a live "= X%" hint and a percent review summary (admin-web FE — tracked in FE-UI-v3). Still forbidden: any bidirectional percent↔bps conversion at the storage / input / transport layer — only a read-path formatter is allowed (precision is lossless: percent capped at 2 decimals ↔ integer bps). Fee rates (*_pct) were already percent and are unaffected. Applied in apps/web (risk tier, early-penalty, portfolio badges, invest sidebar, risk disclosure, overview) + admin review summary; CLAUDE.md rule #7 reworded.

Docs: 14-decisions v3-73, CLAUDE.md rule #7, Notion FE-UI-v3.

📅 July 13, 2026 — DPD/NPL model + risk-badge NPL signal (v3-72)

📉 v3-72 — DPD/NPL data model & risk badge

Intern SEA/OJK research finalized how Aset ingests delinquency data. NPL and cumulative loss are two lifecycle stages: a loan past npl_threshold_days (fund-reported, default 90) is non-performing but still on the book (a leading, unrealized signal); once past the fund's write_off_policy (discretionary; SEA P2P ~180 DPD) it is written offrealized cumulative_loss, embedded in NAV. DPD buckets are dynamic (fund-reported thresholds). The risk badge now uses three distinct signals — nav_per_token (realized), npl_ratio (leading, NEW/S2: >5% High, 2–5% Medium), and structural — correcting the earlier "NPL embedded in NAV" note; cumulative_loss is display-only. Aset does not compute provisioning ratios. Fund-data: npl_threshold_days + write_off_policy added, sector_breakdown displayed; dpd_bucket_standard / dpd_denominator / OJK collectibility_categories dropped. DB (CH — migration 0073 additive + 0074 cumulative_impairmentcumulative_loss rename; 0071/0072 were already taken): new snapshot time-series columns + external_pool_dpd_buckets child table + pools static config. Docs corrected: 04-pool-models, 06-writedown-nav.

Docs: 14-decisions v3-72, 04-pool-models, 06-writedown-nav.

📅 July 13, 2026 — Field-governance matrix corrected against contracts (v3-71)

🔎 v3-71 — Field-governance matrix corrections

Walking 24-field-governance against the contracts fixed several misclassifications. min_investment and capacity are on-chain enforced (not off-chain policy): minInvestment has no setter (immutable → §5), capacity/hardCap is raise-only with no timelock (§2) — so the former §6 C-lock is now empty. accepted_currencies is not immutableaddStablecoin/removeStablecoin work only while the pool has no LP, then freeze (go-forward policy: add-only). The "Oracle / periphery address" row was removed: the oracle is an instantly-grantable role bounded by its narrow scope (it cannot move funds), not a timelocked field; "periphery" = the KYC implementation. perf_fee_pct removed (performance fee not implemented). The Yield-claim-recipient note was reworded — no recipient field exists, claimYield always pays its own caller. The stale kyc_level_required row was removed — pool-level KYC/KYB level gating was already dropped in migration 0063 (pools gate by jurisdiction only); accredited/QP (investor_tier) gating is a separate backend-side plan (see the KYC/KYB & investor-tier spec). Also documented: reserve_bps has no direction constraint (raise or lower via the 7d timelock). Pending sign-off (not yet in code): making KYC implementation upgrades instant (⚠️ removes the only upgrade timelock — needs security sign-off); and confirming whether the NAV 24h-decrease delay is a legal requirement. Doc-only for the corrections. The stale KYC gating docs (03-kyc-identity + 04-pool-models) were synced to match — level gating removed (kyc_level_required / requiresInstitutional / KYC_ONLY·KYB_ONLY·EITHER all gone), SBT US-person is country-derived (no separate attestation flag), and the real jurisdiction fields (enforce_jurisdiction / allows_us_persons) documented. CLAUDE.md kyc_level enum values fixed (INDIVIDUAL/INSTITUTION, not KYC/KYB).

Docs: 14-decisions v3-71, 24-field-governance, 03-kyc-identity, 04-pool-models.

📅 July 13, 2026 — Aligned 5 DB↔contract field names (values unchanged, names only)

🔗 DB ⇄ contract name alignment

Unified five concepts to a single name across DB column, Solidity struct field, and app camelCase. Values and semantics are unchanged — names only.

Contract struct-field renames (struct members, not functions/events/errors → no function-selector or event-topic change; ABI regenerated):

  • PoolConfig.hardCapcapacity (matches DB capacity). ⚠️ The raiseHardCap / setHardCap governance action and the HardCapNotRaised error are unchanged — only the config field was renamed.
  • RedemptionConfig.flatFeeAmountpenaltyFeeAmount (matches DB penalty_fee_amount).
  • RedemptionConfig.noticePeriodDaysstandardRedemptionDays (matches DB standard_redemption_days).

DB column renames (migration 0070; contract already used the target name):

  • pools.redemption_epoch_daysepoch_duration_days (matches on-chain epochDurationDays; app redemptionEpochDays/epochDaysepochDurationDays).
  • pools.kyc_jurisdiction_whitelistjurisdiction_whitelist (matches on-chain jurisdictionWhitelist; app kycJurisdictionWhitelistjurisdictionWhitelist).

No type/semantics change; no table/enum count change.

Docs: 11-db-schema, 08-smart-contracts, 04-pool-models, 07-redemption, 14-decisions.

📅 July 13, 2026 — Renamed contract `RedemptionConfig` field `earlyRedemptionPenaltyBps` → `penaltyRateBps`

🔗 Contract field name unification

The on-chain RedemptionConfig struct field earlyRedemptionPenaltyBps was renamed to penaltyRateBps so the contract field name matches the DB column penalty_rate_bps (name unification; the value was already bps). The ABI was regenerated. Because it is a struct member (not a function or event), there is no function-selector or event-topic change.

Docs: 08-smart-contracts, 14-decisions, 11-db-schema.

📅 July 13, 2026 — Consolidated the two early-penalty bps columns into `penalty_rate_bps` (migration 0069)

🔗 Early-penalty column consolidation

Consolidated the redundant early_redemption_penalty_bps into penalty_rate_bps (migration 0069). early_redemption_penalty_bps was dropped; penalty_rate_bps is now the single early-redemption-penalty field, and the backend feeds the contract's on-chain RedemptionConfig.earlyRedemptionPenaltyBps from it for all penalty types. The separate admin early-penalty input was removed. No on-chain change (the contract struct field is unchanged); no table/enum count change.

Docs: 11-db-schema, 08-smart-contracts, 14-decisions.

📅 July 13, 2026 — Pool ratio fields unified to integer basis points (bps) (migration 0068)

🔢 bps unification — reserve_bps / redemption_gating_bps / penalty_rate_bps

Three pools ratio columns are renamed and retyped from mixed percent/fraction NUMERIC to integer basis points (bps) so they match the on-chain convention (amount × bps / 10000) with no bridging conversions. 1% = 100 bps, 100% = 10000 bps.

  • reserve_percentage (percent, e.g. 10) → reserve_bps (INTEGER NOT NULL DEFAULT 1000, CHECK 0..10000). Old values migrated ×100.
  • redemption_gating_pct (percent) → redemption_gating_bps (INTEGER, nullable, CHECK null or 0..10000). Old values migrated ×100.
  • penalty_rate (0–1 fraction, e.g. 0.50) → penalty_rate_bps (INTEGER, nullable, CHECK null or 0..10000, e.g. 5000). Old values migrated ×10000.

Formulas become reserve = deposit × reserve_bps / 10000, penalty = base × penalty_rate_bps / 10000, gating cap = redemption_gating_bps × totalDeposited / 10000. pool_governance_changes.change_type enum values (RESERVE_BPS, REDEMPTION_GATING) are unchanged — only their new_value is now bps. penalty_rate_bps and early_redemption_penalty_bps are now redundant in unit (consolidated in migration 0069 — see the entry above; early_redemption_penalty_bps was dropped and penalty_rate_bps is the single field). Migration 0068_unify_ratio_fields_to_bps.sql. No on-chain change (contract already used bps).

Docs: 11-db-schema, 04-pool-models, 24-field-governance, 07-redemption, 08-smart-contracts, 23-money-path.

📅 July 10, 2026 — Money-path naming unified + canonical 23-money-path doc (v3-70)

🗺 v3-70 — Money-path naming unification + canonical doc

Wallet/bank-account terms are unified on a single capital vs. fee axis, wallet and its fiat off-ramp sharing a prefix: investor capital = fund_walletfund_bank_account; FM Pool-mgmt fee = fund_fee_wallet (v3-69, pending) → fund_fee_bank_account; Aset fee = treasury_wallet (canonical, supersedes loose "treasury"). Reserve is not a wallet/contract — it is reserveBalance held inside PlatformPool (no "reserve contract"; PlatformEscrow removed in v3-11). pool_wallet is deprecated (dead legacy AS_POOL column). Chosen so no DB migration and no on-chain change are needed — live identifiers already match; only the fund-data sheet (pool_bank_accountfund_bank_account; old fund_bank_accountfund_fee_bank_account) and doc prose are aligned. New canonical doc 23-money-path consolidates the three streams, the on-chain ↔ off-platform (OTC/fiat) boundary, and confirms redemption is role-gated with no fixed address. Cross-links added from 04 & 09a; sidebar entry under Protocol.

Also adds 24-field-governance — a one-glance internal matrix consolidating, per governance-relevant fund/pool field, its storage (DB/on-chain), editability class, editor, and timelock (values cross-referenced to the 08 timelock SoT, not duplicated).

Docs: 14-decisions v3-70, 23-money-path, 24-field-governance, 04-pool-models, 09a-custody.

📅 July 9, 2026 — Fee structure: 3 recipients, Pool mgmt → fund_wallet at distribution (v3-69)

💰 v3-69 — Fee split by recipient

BD finalized the go-forward fee design for next-year listed products (current FJO Aset fee = 0). Fees now have 3 recipients: ① Aset (treasury) ← Platform yield-take + SPC mgmt (+ Perf); ② Fund (separate fee wallet)Pool mgmt (fund manager's fee, sent directly to a separate fund_fee_walletdistinct from the deposit money-path fund_wallet — at distribution; per-deal rate in DB pool_mgmt_pct, fee amount computed off-chain (Lambda) as today, rate + fee-wallet changes apply immediately, not timelocked — fee auto-distributes with no hold, so only the deposit fund_wallet keeps its timelock); ③ operation (off-chain, not investor fees) ← Investor sourcing (distributing partner) + on/off ramping (Aset → OTC per deal). On-chain: the only required change is withdrawFees sending Pool mgmt to a 2nd fixed destination — the separate fund_fee_wallet (≠ deposit fund_wallet); the fee amount stays off-chain (Lambda) — not yet implemented (CH handoff); deposit money-path (90/10, immutable) unchanged, distribution stage only. DB: net_yield_fee_config renames admin_fee_pctplatform_yield_take_pct and splits mgmt into spc_mgmt_pct (→Aset) / pool_mgmt_pct (→ separate fund_fee_wallet). The old entry-fee skim design is dropped (sourcing = operation).

Docs: 14-decisions v3-69, 04-pool-models.

📅 July 9, 2026 — NAV guard rails (circuit breaker / deviation cap / staleness) are operational, no investor UI (v3-68)

🛡 v3-68 — NAV guard rails have no investor-facing display

Guard-coverage audit (#12) asked whether the investor app should show the on-chain circuit breaker / NAV deviation cap / staleness guard. Decision: these are operational, not investor-facing. On-chain NavLib._checkNavBound enforces a circuit breaker + bidirectional deviation cap on every updateNAV; RedemptionLib.executeEpoch enforces staleness at settlement. The backend already pre-flights a NAV proposal with simulateUpdateNav (eth_call dry-run) → decodes the revert into a clean 4xx (no wasted gas, no DB drift; cap/staleness have no on-chain getter, so simulate is the SoT). No investor UI: breaker reset / cap set are PAUSER/ADMIN multisig actions with no investor-actionable state; settlement staleness surfaces via the epoch scheduler's epoch_circuit_breaker / epoch_execute_failed ops alerts. Investors see NAV movement through the writedown banner + NAV history. No contract/DB/frontend change — doc clarification of an already-enforced invariant.

Docs: 14-decisions v3-68, 06-writedown-nav.

📅 July 8, 2026 — Epoch redemption gating cap base = `totalDeposited`, not TVL (v3-67, doc/comment correction)

🚰 v3-67 — Gating cap base = totalDeposited (not TVL)

Docs (04-pool-models, 07-redemption) and the contract's own comments/tests labeled the epoch redemption gating cap as gatingBps × TVL. The actual code (RedemptionLib._epochFillRatio) uses gatingCap = totalDeposited × redemptionGatingBps / 10000. Corrected the label everywhere: base is totalDeposited — the on-chain deposited-capital accumulator (deposits + reinvested yield − payouts), not TVL (LP supply × NAV). They diverge under writedown / undistributed yield, so gating measures book capital, not market value. No behavior change — code was always totalDeposited; only docs + comments + test comments fixed.

Docs: 14-decisions v3-67, 04-pool-models, 07-redemption.

📅 July 8, 2026 — `NO_EARLY` = no lockup (v3-66)

🔓 v3-66 — NO_EARLY means no lockup

Docs (04/05/07 + CLAUDE.md) described penalty_type = NO_EARLY as "no penalty on early redemption, so the investor can redeem even during the lockup" — implying a penalty-free-but-still-locked window, and even listed a NO_EARLY + LOCKED badge state. Corrected: NO_EARLY ⟺ no lockup (lockup_days = 0). A NO_EARLY pool has no LOCKED state — the investor can redeem anytime, penalty-free from deposit. A lockup (lockup_days > 0) only pairs with a penalty type (FLAT_FEE / PRINCIPAL_BASED / YIELD_BASED). Matches the existing 07-redemption edge case ("if lockup_days = 0, skip LOCKED"). Docs-only correction; enforcing the invariant in pool create/edit validation is a recommended follow-up.

Docs: 14-decisions v3-66, 04-pool-models, 05-investment-lifecycle, 07-redemption.

📅 July 7, 2026 — Fund Data grounding: total_subscribed, fund service providers, NPL write-offs (v3-65)

🗂 v3-65 — Fund Data grounded to real columns

PDP Fund Data review found a stat with no backing column and a delinquency block implying a full loan-book DPD distribution Aset doesn't receive. Grounded it (migration 0054):

  • Total subscribed — persisted as external_pool_data_snapshots.total_subscribed (fund-reported, already a fund-data request field / summary.fund.totalSubscribed); stamped onto snapshot rows at sync. History API returns it.
  • Service providers — new fund-level table fund_service_providers (auditor/administrator/custodian/legal); funds POST/PATCH accept service_providers[] (full-replace), GET /funds/{id} embeds them; admin create/edit fund gets a providers editor; investor Fund Data tab shows them read-only (empty state when none).
  • Delinquency = NPL/write-offs — Fund Data "Written-off loans" block reads loan_writeoffs via new GET /pools/{id}/fund-data/writeoffs (charged-off NPL cases, not a full DPD distribution); empty state when none.
  • tsc -b green (infra/admin-web/web).

Docs: 14-decisions v3-65, 11-db-schema.

📅 July 6, 2026 — Refresh token moved to an HttpOnly cookie (S-1)

🔒 S-1 — Refresh token in HttpOnly cookie (web + admin)

Access/refresh JWTs were stored in localStorage, so an XSS could steal the 30-day refresh token and take over the session. The access token keeps using the Authorization header, but the refresh token now lives in an HttpOnly cookie JS can't read.

  • Backend: /auth/verify (investor) and /auth/admin-login · /auth/admin-2fa/verify · /auth/admin-oauth (admin) issue the refresh token as Set-Cookie: refresh_token=…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=30d (lib/shared/auth/cookies.ts) instead of in the body. /auth/refresh reads it cookie-first (body fallback for the deploy window). New POST /auth/logout clears the cookie (web + admin sign-out); admin/logout-all also clears it.
  • CORS: credentialed cookies forbid allowOrigins:'*' → API Gateway CORS is now an explicit web/admin origin whitelist + allowCredentials:true (environment.ts corsAllowedOrigins); createResponse drops its static '*' (HTTP API owns CORS).
  • Frontend: refresh token no longer in localStorage; a non-sensitive session_hint (expiry ts) gates refresh attempts; withCredentials only on verify/refresh/logout.
  • CSRF: no separate token — SameSite=Lax blocks cross-site requests from carrying the cookie, and the /auth/refresh response is unreadable cross-origin.
  • Follow-ups (separate tracks): refresh-token rotation / server revoke, access-token expiry alignment, CSP headers.
📅 July 4, 2026 — Reinvest is same-pool only; cross-pool reinvest removed (v3-64)

🔁 v3-64 — Reinvest = same-pool only (no cross-pool)

Docs framed "cross-pool reinvest" as a future capability and the portfolio UI shipped a "Target Pool" dropdown listing all active pools — implying pool A's yield could be reinvested into pool B. Structurally impossible: on-chain reinvest(uint256) spends that pool's accruedYield and mints that pool's LP (no target param); the handler only reinvests the given pool's own yield.

  • Rule: reinvest is same-pool only, by design — not a V2 roadmap item. To move yield elsewhere, claimYield() to wallet then deposit() into the other pool.
  • FE: removed the Target Pool selector + cross-pool branch (portfolio.tsx); reinvest always targets the source pool. tsc -b green.
  • Docs: 02-core-concepts / 05-investment-lifecycle reworded. Backend/contract unchanged (already same-pool).

Docs: 14-decisions v3-64, 05-investment-lifecycle.

📅 July 2, 2026 — Reserve recovery shown pool-level, not per-holder (v3-63)

📊 v3-63 — Recovery rate is pool-level

The PDP reserve-recovery bar was framed per-holder — misleading, since every holder in a tranche is repaid at the same pro-rata rate in wind-down. Reframed to a pool-level recovery bar (nav_per_token vs par, reserve_percentage / nav_history.reserve_consumed) + the viewer's own expected amount + "everyone recovers at the same rate" copy. No backend. ✅ Built (position-card.tsx, WIND_DOWN).

Docs: 14-decisions v3-63, 07-redemption.

📅 July 2, 2026 — Number / USD / date display conventions (v3-62)

🔢 v3-62 — Display formatting standardized

Numbers use tabular-nums + consistent decimals + decimal-aligned; USD is compact on cards ($28.5M) and full in tables/tooltips/detail ($142,400); dates are relative for countdowns (15 days left) and absolute for fixed dates (Jul 15, 2026). Shared formatUsd() / date helpers, applied app-wide with the SectionCard / StatCard tokens. USD-only (v3-24) retained. FE presentation only — no schema/contract impact.

Docs: 14-decisions v3-62, design NOTES.md.

📅 July 3, 2026 — Per-investor cap (`max_investment`) hidden, contract field dormant (v3-61)

🙈 v3-61 — max_investment hidden (unlimited by default)

max_investment (on-chain per-investor cumulative cap, 0 = off) is hidden from the create/edit forms and always sent as 0; forms keep only capacity. It's non-standard vs benchmarks — per-investor control belongs to KYC/eligibility gating. Contract field left dormant (Clones immutable; 0 is a no-op → no redeploy). Re-attach the UI later if anti-whale / regulatory limits are needed. Regulatory fit = separate legal check.

Docs: 14-decisions v3-61, 11-db-schema.

📅 July 7, 2026 — Country code canonical format = ISO alpha-3 (v3-60, reversed from alpha-2)

🌍 v3-60 — Canonical country code = alpha-3 (reversed)

Jurisdiction gating compares the pool whitelist against the SBT countryCode. SumSub returns alpha-3 (IDN/KOR) stored raw, so SBTs and the on-chain whitelist validation are already alpha-3 end-to-end — only the off-chain admin UI + kyc-gating.ts comparison + contract docstring assumed alpha-2, silently rejecting everyone when gating is on. Decision (2026-07-07): canonicalize on ISO alpha-3 ("KOR" not "KR"), reversing the July 2 alpha-2 decision — the alpha-2 path needed a BE normalization layer that never shipped, and alpha-3 is already the live reality. No BE normalization; fix the off-chain alpha-2 assumptions instead: admin whitelist UI hint → alpha-3, add alpha-3 validation on create/update, correct the 08a countryCode docstring, migrate any legacy alpha-2 rows/SBTs.

Docs: 14-decisions v3-60, 04-pool-models, 03-kyc-identity, 08a-contract-reference.

📅 July 3, 2026 — DPD delinquency buckets generalized to a dynamic model (v3-59)

🗄️ v3-59 — DPD buckets go dynamic (fixed 30/60/90 → jurisdiction-defined)

The PDP delinquency ingest hard-coded 30/60/90-day buckets, so it couldn't receive other regimes (e.g. OJK collectibility: current / special mention / substandard / doubtful / loss on 90/120/180). Generalized the data model so each fund reports its own buckets.

  • DB (Option A — child table): pool_dpd_buckets(snapshot_id, lower_days, loan_count, exposure), one row per bucket; the risk snapshot gains dpd_bucket_standard + npl_threshold_days (default 90), optional dpd_denominator.
  • FE: no change — the PDP-1 delinquency renderer is already dynamic and absorbs the bucket array.
  • Seams to build (🚧 To Do): partner Google Sheet ("Data Mapping": fixed object → array + 3 new fields), DB migration, ingest/API mapper (accept array, sort by lower_days, validate). No on-chain impact (DPD is ingest/display-only, not the NAV pipeline). Prereq for the intern SEA/OJK research.

Docs: 14-decisions v3-59, 06-writedown-nav, 20-joob-pool-config, 11-db-schema.

📅 July 2, 2026 — Permissionless LP transfers: whitelist gate removed (v3-58)

🔓 v3-58 — LP secondary transfers are permissionless

PlatformLPToken gated investor-to-investor transfers behind an admin whitelist mapping (revert NotWhitelisted unless both parties allowlisted) — but no backend ever called setWhitelist, so the mapping was empty and secondary transfers were effectively blocked for everyone, contradicting the 5-state model's State C (unverified holder who received LP via transfer). Removed the gate entirely.

  • Verification moves to the value boundaries (already true on-chain): deposit() = requiresKYC, requestRedemption() / claimYield() = requiresRedeemableKyc. A wallet can freely hold LP (State C) but can't deposit/redeem/claim until KYC (State A). "Gate value in, never trap value out" (v3-19).
  • Contract: removed whitelist / setWhitelist / NotWhitelisted / WhitelistUpdated + the two _update checks; kept _requireNotPaused() (admin can still emergency-freeze secondary transfers) + onLpTransfer yield settlement.
  • Tests: 291/291 green (removed 3 whitelist-only tests, flipped 2 revert tests to a permissionless positive test, dropped setup setWhitelist across 6 suites). No backend/app referenced the whitelist.
  • Not retroactive: clones immutable — needs a new LP impl + Factory redeploy to apply to newly created pools.

Docs: 14-decisions v3-58, 01-overview, 08-smart-contracts, 08a-contract-reference, 21-holder-verification, 05-investment-lifecycle.

📅 July 2, 2026 — Pool Updates (WO-6) RBAC formalized + Operator opt-in (v3-57)

📣 v3-57 — Pool Updates / Announcements permissions

The v3-22 / WO-6 announcement feed shipped (migration 0012; POST /pools/{id}/updates, GET /pool-updates, PATCH/DELETE /pool-updates/{id}) but its RBAC lived only in code. Formalized + one behavior change:

  • Create / edit / delete = SUPER_ADMIN, ADMIN, FUND_MANAGER, and OPERATOR opt-in via the admin-granted pools page permission (was: Operator hard-excluded). pool-updates.post.create / .patch.update / .delete now add OPERATOR + requirePagePermission('pools').
  • FM = own-fund pools only, own posts for edit/delete; Admin / Operator / Super Admin = any entry.
  • Categories INFO (feed only) / IMPORTANT / MATERIAL_EVENT (email holders on publish); no admin-review gate before an FM material-event blast.
  • Delete is soft-only (deleted_at, retained — no hard purge); MATERIAL_EVENT edits snapshot to pool_update_revisions. No updates on DRAFT pools.
  • Docs: 09-rbac → Pool Updates, 14-decisions v3-57. Infra tsc green.
📅 July 2, 2026 — Full-amount LP minting: reserve split no longer dilutes the investor's claim (v3-56)

🪙 v3-56 — deposit()/reinvest() mint LP on the FULL amount

Since the first contract (2026-02-03), LP was minted net of reserve ((amount − reserve) / nav) — a $100 deposit at 10% reserve minted only 90 LP, silently cutting the investor's redeemable claim; the reserve was never individually credited back (only pro-rata at WIND_DOWN). This was implementation inertia, never a recorded decision. Now lpAmount = amount × NAV_PRECISION / navreserve and LP are independent.

  • Money flow unchanged: reserve still retained in reserveBalance, remainder still released to fund_wallet (10/90 split, v3-11). Only the claim accounting changed.
  • Backend mirror synced: calculateLpTokens() (deposit cross-check + reinvest DB credit — must match on-chain) updated in the same change; 26 tests updated, 295/295 green.
  • Not retroactive: clones are immutable — pools created under previous factories keep net-of-reserve minting. Old Sepolia test pools should be archived/recreated; reinvest against them would over-credit by reserve_percentage.
  • Redeployed 2026-07-02: new PoolImpl 0x2dB0…19C2 + Factory 0x8D28…40a1 (KYC proxy + LP impl reused). See apps/contract/sepolia.md.

Docs: 14-decisions v3-56, 05-investment-lifecycle (step ④ formula).

📅 July 1, 2026 — Docs↔code sync pass (no decision; stale-doc corrections)

🧹 Documentation sync — reconcile spec with shipped code

A full docs-vs-code audit surfaced stale statements where the spec lagged the implementation (or described deferred work in present tense). Corrected, no architecture change:

  • 01-overview Migration Status — flipped from "📋 Spec only" to built (contracts ✅ match spec, DB ✅ through migration 0043, ~128 Lambdas ✅, both FEs ✅); remaining work is deploy/QA.
  • 11-db-schema counters — header + footer corrected to 40 tables · 26 enums · latest migration 0043 (were 39/40 · 27 · 0040), verified 2026-07-01. Table bodies were already accurate.
  • Phantom function names — removed references to non-existent redeem() (08a, 10) and Pool.executeRedemption() (07); real paths are requestRedemption → claim, fundRedemption auto-complete, and claimRedemptionFallback.
  • Stale "🚧 In Development" banners softened03-kyc-identity (SumSub / per-pool gating / reuse all built) and 21-holder-verification (resolver + provenance built; State-C producers still pending). 09-rbac read-authz marked implemented (resolveReadScope).
  • 04-pool-modelsis_emergency_frozen marked built; maturity_model value clarified as OPEN_ENDED ("REVOLVING" is display-only, would 400 if sent).
  • Deferred work labeled — automated DPD→OJK NAV writedown (v3-13) marked 🚧 deferred/unbuilt in 06-writedown-nav + 20-joob-pool-config (blocked on Joob per-loan data; NAV is manual today). Wind-down "60+30 day" clarified in 10-status-machines — only the 30-day on-chain timelock is enforced.
  • CLAUDE.md user-flows — Invest/Reinvest fixed to atomic platform-issued LP (no Model A/B), Redemption fixed to single-stage approve (no recommend/complete), reinvest endpoint fixed to POST /yield/reinvest.
📅 July 1, 2026 — FM wallet signing hardening: fund_wallet visibility + client-sign enforcement + bound-wallet display (v3-54)

🛄 v3-54 — FM wallet signing hardening (W8/W9)

Closes the three gaps v3-53 left before the dev fund_wallet == adminWallet shim can be removed (Notion W8a/W8b/W9). 0 contract changes, 0 migrations.

  • W8a — fund_wallet visible to FM: the full fund-wallet display (address + explorer + copy) was Admin-only (Controls tab). Extracted to a shared FundWalletDisplay and added as a read-only row in the Overview "Pool Config Summary" (all roles). No API change.
  • W8b — no silent server-key fallback: depositYield / fundRedemption routes now require client-signing whenever a pool has a fund_wallet — if the matching wallet isn't connected the action is blocked with an explicit error, never the server key. Simplified from the planned fund_wallet == adminWallet branch (admin-web has no admin-wallet address): dev/admin server-key convenience is dropped; dev connects the fund_wallet directly. FE-only.
  • W9 — bound-wallet display + rebind: new GET /admin/me/wallets (own wallets only, scoped by auth.sub); My Settings lists verified wallets + connect/re-verify (rebind). Actual on-chain fund_wallet change stays admin governance (7-day timelock) — FMs get a notice to request it, no FM-executed governance.

Deploy order unchanged: W8 merge → remove dev shim → real fund_wallet. Docs: 14-decisions v3-54, 15-api-reference.

June 2026 40 entries

📅 June 30, 2026 — Partner fund-data metrics renamed asset-class-neutral (v3-55)

🏷 v3-55 — total_rni/total_npl → realized_income/cumulative_impairment

Renamed the two partner fund-data metrics on external_pool_data_snapshots to asset-class-neutral names. total_rni ("Realized Net Income") was credit-specific and mislabeled "Net" (the value is gross of fees) → realized_income; total_npl ("Non-Performing Loans", 90d-default loan framing) → cumulative_impairment so non-credit funds (RE / receivables / fund-of-funds) map cleanly.

  • Non-breaking: ingest + display only — not used in NAV (DPD-based, v3-13/14) or any on-chain call.
  • Partner unaffected: the Joob wire fields totalRni/totalNpl are unchanged; the provider mapper is the seam that maps partner naming → our canonical columns.
  • Renamed across DB column + domain types + API response + FE (label "Realized Income" / "Cumulative Impairment"). Migration 0043 (RENAME COLUMN). Partner intake sheet wording updated separately (incl. the "Net" fix).

Docs: 14-decisions v3-55, 11-db-schema.

📅 June 30, 2026 — FM wallet signing: depositYield / fundRedemption client-signed (v3-53)

🛄 v3-53 — FM signs money-in from their own wallet (non-custodial)

The two on-chain functions the contract gates to the partner — depositYield + fundRedemption (YIELD_DEPOSITOR_ROLE) — were signed by the Aset server key via the dev fund_wallet == adminWallet shim (custodial; breaks in production). v3-53 moves both to FM client-side signing from the FM's own wallet (Model A: connected wallet is the pool fundWallet, 0 contract changes).

  • FM wallet identity (B3): FM proves ownership via SIWE; proven wallets stored in new admin_user_wallets (migration 0041, one FM → N wallets). Auth SoT stays on-chain (msg.sender == pool.fund_wallet); the table is the pre-sign guard / display / audit cache. Bind is session-less (POST /admin/wallet/nonce + /verify, no JWT) — Google-OAuth session untouched.
  • Yield split: POST /yield-distributions with a new deposit_tx_hash (migration 0042) creates the row in PROCESSING (reuses unused yield_status value, no enum change); new POST /yield-distributions/{id}/distribute verifies the deposit on-chain then runs server-key distributeYield+withdrawFees (shared run-distribution.ts). Legacy/dev path unchanged.
  • Redemption split: FM signs fundRedemption; POST /redemption-requests/{id}/record-funding only records the tx (funding_status='PENDING_FUND_CONFIRMATION', no migration). The indexer settles via RedemptionCompletedcomplete_redemption_atomic (a /complete endpoint was rejected — would race the indexer).
  • FE (admin-web): wagmi/RainbowKit providers, SIWE bind UI, use-deposit-yield-flow/use-fund-redemption-flow (ERC20 approve→action 2-tx) + use-network-guard, full fund_wallet display (explorer+copy), pool-create fund_wallet input, FM visibility gate removed.

Phased 1→3. Contract: 0 changes. Deploy: pnpm cdk:deploy + admin-web deploy + migrations 0041/0042 (applied to dev) + remove the fund_wallet==adminWallet dev shim in order. Follow-ups: Safe async execution (EOA-first today), per-pool signing deep-links.

Docs: 14-decisions v3-53, fm-wallet-signing-spec.

📅 June 29, 2026 — Investor display name = KYC legal name + SumSub backfill (v3-52)

🪪 v3-52 — Investor display name (KYC legal name, non-editable) + backfill

The Settings page showed "Anonymous Investor" for everyone. Migration 0040 added users.first_name/last_name and apply-review (GREEN) now mirrors the SumSub-verified name. Decided (option A): the display name is the KYC legal name, not investor-editable — no alias/display_name column. Rationale: per WO-4 the Fund Manager sees investor name + wallet for KYC/AML, so a self-asserted alias would defeat the compliance value. Settings renders it read-only.

  • Backfill — existing approved investors (KYC'd before 0040) have NULL names → still "Anonymous". kyc.scheduler.reconcile-sweep gains backfillMissingNames(): APPROVED + sumsub_applicant_id + all name fields NULL → getApplicantInfo → fill first_name/last_name (KYB → company_name). No re-verification, no new PII (mirrors data SumSub already holds). Self-draining; 25/run.

No new migration (rides 0040). Deploy: pnpm cdk:deploy + migration 0040 applied — until then names stay NULL by design.

Docs: 14-decisions v3-52.

📅 June 29, 2026 — Admin can manage Admins (v3-47 revised)

🔑 v3-47 revised — an ADMIN may edit-name + delete another ADMIN

PM-confirmed relax: an ADMIN (not only SUPER_ADMIN) can now edit the name + delete another ADMIN — and Operator / FM. SUPER_ADMIN stays protected (never deletable) and the last-Admin guard remains. The original v3-47 said the BE enforced this, but DELETE / PATCH /admin-users/{id} were withRole('SUPER_ADMIN') with no target check (an ADMIN got 403). Now withRole('SUPER_ADMIN','ADMIN') + server-side target guards (block SUPER_ADMIN target, block granting SUPER_ADMIN role, last-Admin) so the relaxed FE rule can't be API-bypassed.

Docs: 14-decisions v3-47.

📅 June 29, 2026 — Yield reconciler: accurate claimable_yield (미청구 + 적립) (v3-51)

🔁 v3-51 — Yield reconciler (claimable_yield = on-chain pendingYield)

The app's "claimable yield" read only portfolio_positions.accrued_yield (the admin-distributed mirror), which drifts from on-chain truth on direct on-chain distributions/claims and LP transfers. The contract already exposes pendingYield(investor) = settled + unsettled total, but nothing read it back (#7).

  • portfolio_positions.claimable_yield + claimable_yield_synced_at (migration 0038) — reconciled on-chain total, human USD; NULL synced_at → FE falls back to accrued_yield.
  • yield.scheduler.reconcile (hourly) — reads pendingYield(holder) per pool holder, upserts claimable_yield. Read-only on-chain + DB writes.
  • YieldClaimed indexer (writeYieldClaimed) — refreshes claimable + settles the yield_claims ledger on direct on-chain claims.

BE/indexer only — no contract change. FE display wiring (claimable_yield ?? accrued_yield) is a follow-up. Deploy: pnpm cdk:deploy (new scheduler + indexer event) + migration 0038.

Docs: 14-decisions v3-51.

📅 June 29, 2026 — Tranche loss waterfall: build BE capability (loss-only) + create-flow Method A (v3-50)

🥪 v3-50 — Tranche loss waterfall (BE capability)

v3-14 + v3-39 defined tranches and the off-chain trust model, but the loss-waterfall computation was never builtnav-changes.post.activate.ts only marks the target pool's own NAV; no group lookup, no Junior-first absorption, no Junior→IMPAIRED. So tranche pools "exist in name only." (Group-constraint validation at create is built: pools.post.create.ts.) With a confirmed Senior-tranche deal coming, decided to finish the BE now — contract impact zero (Lambda computes per-pool NAV then calls updateNAV() each) so it's off the audit critical path.

  • Scope = loss waterfall only (Junior→Mezz→Senior) + Junior→IMPAIRED (v3-12). Yield/profit waterfall deferred until a deal needs Senior-APY-first.
  • Roles (tranche_role/tranche_group_id) set explicitly at pool-create only, immutable after (no edit path today; hard-freeze once any deposit exists). Soft-warn if Senior APY ≥ Junior APY.
  • Create flow = Method A (incremental): pool-create gets a Standalone/Tranche toggle → group (new/existing) + role radio (taken roles disabled) + shared fields locked from the group. Chosen over a one-shot wizard because BE already supports per-pool create+validation and each pool is its own on-chain deploy (atomic multi-deploy is fragile).
  • Completeness guard: a group can't accept deposits until its shield (≥ Senior + Junior) is in place; incomplete group kept non-ACTIVE.
  • Junior retail-gating = FE-only hide, per-deal. No on-chain allowlist (would break contract-zero).
  • Out of scope: yield waterfall · on-chain allowlist · pool-edit validation. (Hiding a specific Junior pool from the investor app stays per-deal.)

Implementation: pure engine lib/shared/tranche/loss-waterfall.ts + tests → group write-down entry fanning out to the existing per-pool propose→timelock→activate→applyPendingNavOnChain path → IMPAIRED trigger → guard. Needs cdk:deploy (user); money-path → QA. Smart-contract change zero.

FE (done):

  • Admin create-flow — pool-create Step 3 Standalone/Tranche toggle → group (new UUID or attach existing) + role radio (taken roles disabled) + fund/maturity auto-locked + soft Senior-APY-≥-Junior warning.
  • Admin pool listTranche · {role} pill + amber "Incomplete · needs Senior/Junior" badge for groups missing the shield.
  • Investor (#18/#20) — Senior/Junior/Mezz pill on pool card + detail header, a tranche row in Risk Disclosures, and risk-tier now reads tranche_role per v3-25 (Junior→High; Senior & Mezz→Medium; standalone stays Low).
  • Data layer in both apps (PoolRow/PoolView/CreatePoolPayload) carries tranche_group_id/tranche_role.

Only genuinely per-deal item left: hiding a specific Junior pool from the investor app.

Docs: 14-decisions v3-50.

📅 June 26, 2026 — FM yield-distribution due + overdue notifications (v3-49)

🔔 v3-49 — Yield distribution due / overdue notifications (FM/Op)

The notification inventory had yield_distributed (after a distribution) but no prompt that a scheduled distribution is due, and no overdue escalation — so an FM could silently miss the manual-trigger cadence (v3-20). Added two events produced by the existing daily pools.scheduler.yield-due sweep (ACTIVE pools only):

  • yield_distribution_due — fired once on the next_yield_due → overdue transition ("it's time to distribute").
  • yield_distribution_overdue — escalation when still unrecorded ≥ 3 days past due; re-sent at most every 7 days.

Idempotency without a schema change: due rides the yield_overdue false→true transition (one-shot/cycle); overdue uses a recent-row lookup (related_entity_id is UUID so dedupByEntity would dedupe forever). Recipients = FM + ADMIN (recipient_id = pool; FM scoped to own funds). No new lambda/CDK — rides the existing cron; deploy gated like v3-44/45. Extends the FM V1 notification scope; copy sheet + Notion Yield table need the 2 new rows.

Docs: 14-decisions v3-49.

📅 June 26, 2026 — BE-FE gap audit: fund-member edit + NAV propose wired; retry-lp-mint disabled (v3-48)

🛠 v3-48 — BE-FE gap audit closure

Follow-up to v3-47: audited admin-web for "BE done, FE missing/unreachable" gaps (grep client fn + hook vs component usage).

  • Fund member edit (pure-FE) — PATCH /fund-members/{id} + useUpdateFundMember were unused; added a per-row Edit modal (name / wallet / primary) in fund-detail.tsx.
  • NAV manual propose (pure-FE) — POST /nav-changes + useProposeNavChange were unused; added a "Propose NAV Change" form to the NAV panel (source: admin_override, 24h-timelock notice on decrease, yield-permission gated).
  • retry-lp-mint (✅ removed) — deposits.tsx / dashboard.tsx retry buttons called a non-existent POST /deposits/{id}/retry-lp-mint (404). Removed the retry UI + retryLpMint fn + useRetryLpMint hook: LP is minted in the investor's own atomic deposit() tx (non-custodial) so admin re-mint is impossible — the real retryable mint is the SBT path (kyc.post.mint-sbt, kept). Failed deposits stay visible with a View CTA.

Also flagged (unchanged): needs-BE stubs + dead code (triggerAutoRedemption, unused createAdminUser/createFundMember). Tracker: FE-UI-v3 "🔎 BE-FE 갭 감사".

Docs: 14-decisions v3-48.

📅 June 26, 2026 — Admin user edit (name) + delete reachable for all roles (v3-47)

🛠 v3-47 — Admin user management: edit name + delete reachable for all rows

QA hit two gaps in admin-settings → Admin Users: (1) no name-edit UI (invited admins stuck with a blank name), (2) Delete only reachable for Operators (the Delete button was inside the Operator-only permissions modal; ADMIN / FUND_MANAGER rows had no actionable control). Backend (PATCH / DELETE /admin-users/{id}, useUpdateAdminUser) already existed — a pure FE gap, not separately tracked in FE-UI-v3 (#13/A-9 was marked done).

  • Every row now opens a "Manage User" modal (was Operator-only).
  • Name editable when authorized, read-only otherwise. Authority = delete rule (option A): Admin edits Operator/FM; only Super Admin edits an Admin; no one edits a Super Admin.
  • Delete unified into the same modal (no longer Operator-gated).

Docs: 14-decisions v3-47, 09-rbac → Permission Matrix.

📅 June 26, 2026 — Redemption model (instant vs epoch) clarified as a gating choice, orthogonal to maturity (v3-46)

🔁 v3-46 — Instant vs epoch is a gating-structure choice, not a maturity property

A design-review question — "a FIXED_TERM pool can also be run on by pre-maturity early exits, so why is epoch tied to REVOLVING?" — surfaced that the v3-26 epoch framing over-coupled the redemption model to maturity_model. Clarified (no schema change):

  • redemption_epoch_days is orthogonal to maturity_modelno validation coupling (pools.post.create bounds it to 0–90 only). The type-driven default (FIXED_TERM → 0, REVOLVING → epoch) is a prefill, not a constraint: a FIXED_TERM pool may be created with > 0.
  • The selection axis is run risk ("can concurrent exit demand exceed available liquidity unfairly?"), not whether the pool has a maturity. REVOLVING = always exposed (epoch baseline); FIXED_TERM = demand concentrates at the planned maturity, early-exit tail is sparse + penalty-gated (instant suffices) → opt into epoch only for concentrated / run-prone / writedown-risk pools.
  • Limits: still immutable while live (v3-38) — choose at creation. Epoch makes exits fair under stress; it does not stop a run (insufficient liquidity → pro-rata partial fill). Halting = is_paused / IMPAIRED / is_emergency_frozen; bleed-rate cap = redemption_gating_pct (epoch-only today).

Docs: 14-decisions v3-46, 07 → Why epoch instead of instant, 04 → redemption_epoch_days.

📅 June 25, 2026 — Investor email registration + verification (notification email channel) (v3-45)

📧 v3-45 — Investor email capture + verification

Wallet-login investors get a synthetic email (<address>@wallet.aset.io) at signup, so the notification email channel was non-functional for them (admin/FM have real emails from invites). But legally-required material notices (NAV writedown / impairment / wind-down) need a real deliverable channel — in-app alone ≠ legal "notice". So investors register + verify an email:

  • Migration 0033: users.email_verified_at / pending_email / email_verification_token / email_verification_expires_at.
  • GET /users/me — investor self-profile (email + verification state); the FE had been calling it but no BE handler existed (admin-only /users/{id} rejected me).
  • POST /users/me/email — set pending email + 24h token, send verification link via SES.
  • POST /auth/verify-email — public, token-gated; completes verification (email = pending_email, email_verified_at = now).
  • Send worker skips synthetic @wallet.aset.io addresses (in-app row still delivered).
  • Web: Settings "Email for notifications" section + /verify-email route.

⚠️ Legal-dependent follow-ups: (1) require a verified email before investing? (2) does a material notice need provable delivery (SES delivery events/SNS)? — bundle with the open material-notice threshold/timing question.

Docs: 11-db-schema users, 14-decisions v3-45, new Notification System reference (full v3-44/45 spec). Notion: Notification — UX/Design (§📧 투자자 이메일 채널).

📅 June 24, 2026 — Notification system wiring: copy registry + Direction-B email template + producer infra + payload schema (v3-44)

🔔 v3-44 — Notification implementation track (copy + template + producers)

The Notification PRD (Notion) reconciled three inconsistent SoTs (docs 13 event matrix ↔ actual code producers ↔ FE preferences UI) and confirmed all decisions (D1–D8, channels, sender, unsubscribe-by-legal). This is the first implementation slice in apps/infra:

  • Copy registry lib/shared/notifications/copy.ts — 28-event typed deck (in-app title/summary + email subject/preheader/hero/body/detail/CTA + deep-link + variables), the code mirror of the Notification Copy sheet. EN-only V1 (D7).
  • Direction-B email renderer lib/shared/email/notification-email.ts — one template, per-event slots; severity accent (info #6999fa / critical #ef5a3c), hero + 2-cell detail card + brand-purple CTA; logo via hosted EMAIL_LOGO_URL PNG (SVG blocked in Gmail).
  • Producer helper lib/shared/notifications/notify.tsqueueNotification / queueForPoolHolders; inserts notification_logs rows (status SENDING) with related_entity_type/id set for FE deep-linking.
  • Schema (migration 0032): notification_logs.payload (jsonb — in_app slots for the feed + email send-ready blob for the SES worker) + read_at (in-app unread badge) + unread index.
  • First producer wired: indexer writers/freeze.tspool_frozen / pool_unfrozen / freeze_extended holder notices. tsc clean.

⚠️ Still open: remaining producers (deposit/lp/redemption/yield/NAV/impair/wind-down/KYC/fund-admin meta), the SES send worker (real sendEmail + retry/backoff + throttle), the in-app read endpoints (GET /notifications etc.), and (c) preferences save + POOL/ACCOUNT FE categories. Phase-0 SES domain verify + production access is a BE dependency.

Docs: 11-db-schema notification_logs, 14-decisions v3-44. Notion: Notification — UX/Design (Draft).

📅 June 24, 2026 — Pool field editability: phase-aware (DRAFT vs ACTIVE); capacity raise-only, min_investment locked (v3-43)

🔧 v3-43 — Pool field editability: phase-aware + capacity/min_investment fix

Editability was already specified (08 Field Sync v3-29 Class A/B/C + 04 Editability After ACTIVE), but two gaps surfaced triaging the Admin Pool Create — Draft save/edit bug:

  • capacity / min_investment were mislabeled Class B "on-chain immutable" — they're actually off-chain (Lambda-enforced; absent from the on-chain state table). Corrected to a new C-lock group: after ACTIVE, min_investment is locked, capacity is raise-only (PATCH accepted only if new ≥ current). No redeploy.
  • Class A enumerated (was just is_paused in the table): + is_emergency_frozen, reserve_percentage, fund_wallet, kyc_level_required, kyc_jurisdiction_whitelist, nav_per_token, lifecycle_status — all via dedicated endpoints, never PATCH.

Editability = storage × lifecycle phase. DRAFT = all fields editable (nothing deployed); immutability binds at DRAFT→ACTIVE. Unblocks the Pool Edit bug — it wasn't a missing decision; the Edit UI must be phase-aware (DRAFT shows the Maturity field; ACTIVE renders locked fields read-only with reason, not hidden).

Docs: 14-decisions v3-43, 08-smart-contracts, 04-pool-models.

📅 June 24, 2026 — Fund Manager activity access: fund-scoped operational view; compliance Audit Log stays admin-only (v3-42)

👤 v3-42 — FM sees fund-scoped "Activity"; compliance audit stays admin-only

The Dashboard "Recent Activity" widget and the Audit Log page are the same GET /activity-events endpoint (different windows), so the FM question was really "fund-scope the endpoint or not." Split by what, not by page:

  • Operational activity → FM, fund-scoped. deposits/redemptions/yield/NAV + pool admin actions on the FM's pools (filter audit_feed to pool ∈ FM's funds' pools). Compliance events (PII_ACCESS/KYC_*) are user-targeted → fall out automatically.
  • Compliance Audit Log + CSV export → admin-only. FM is a processor, not the data controller (09-rbac L206), so the compliance record isn't theirs.
  • Same /audit-log route, role-adaptive: new activity PageKey opens it to FM but renders as "Activity" (vs admin "Audit Log"), export hidden. Reconciles the 09-rbac L227 ↔ L184/L231 contradiction (endpoint scoping vs full-compliance-page access).

Fixes the FM Recent-Activity widget returning []. Built on jy/product; no migration.

Docs: 14-decisions v3-42, 09-rbac.

📅 June 23, 2026 — Audit log = unified two-stream feed (human actions + on-chain mirror); 5y retention (v3-41)

🗂️ v3-41 — activity/audit log: two-stream unified feed

activity_events was declared the "single audit source" but only PII_ACCESS is written; meanwhile the on-chain indexer already mirrors every contract event (deposit/redemption/yield/NAV/freeze/LP-transfer) into dedicated tables with tx_hash provenance. Decision: the audit feed has two streams joined at read time

  • On-chain events → READ from indexer tables (UNION view); do not re-INSERT (no double-write / second SoT).
  • Human privileged actions → WRITE to activity_events at the admin-web→API boundary (actor_id from JWT): KYC approve/revoke, freeze/pause, impair/wind-down, redemption approve/reject/hold, fee withdraw, governance proposals, PII access.

Retention 2y → 5y (MAS PSN02 / TH AMLA §22; "2y" was the hot-export window, not the floor). Not a mainnet blocker (audit trail already exists on-chain + indexer) — P1 for unified read-view/CSV/archive, P0-soft for human-actor writers (AML expects staff attribution).

Docs: 14-decisions v3-41, 13-operations.

📅 June 23, 2026 — Preview (showcase) pool tier + register-interest CTA (v3-40)

🪟 v3-40 — preview pool tier (marketing showcase)

Add a preview pool tier — visible but never investable on Aset — to showcase partners' other funds (e.g. Joob/FJL 2–4) and drive interest in future rounds (FJL 5). Source: 6/23 Grab×JOOB wrap-up (Option B).

  • Overview only (high-level profile / deal structure); Performance + Fund-Data hidden (CLOSED currently shows full perf — not wanted here, hence a new tier).
  • Register-interest CTA (notify-me → lead to Aset); also on CLOSED pools (same fund may re-run). Ties to the Notification track.
  • Spec at the tab level ("Overview only"), not field level (FE may change).
  • Display tiers (Preview / Display / Full) drive which partner API fields are required — preview needs static profile only (broad Required); ongoing/risk/time-series not needed.
  • Refines FE-UI #19 ("display-only pool"). FE build, no contract impact.

Docs: 14-decisions v3-40, 04-pool-models, 10-status-machines.

📅 June 23, 2026 — Tranche first-loss = v1 off-chain (Lambda) trust model; on-chain waterfall deferred (v3-39)

🥪 v3-39 — tranche first-loss: trust model (v1), on-chain waterfall later

Tranche loss/yield waterfall stays off-chain (Aset Lambda) per v3-14 — Junior/Senior are separate pools, Lambda marks the Junior pool's NAV down first ("smart contract impact: zero"). So subordination is a trust model, not contract-enforced.

  • v1 = accept it. Junior = partner-provided first-loss, its own pool, retail-gated (UI-hide + KYB). Thickness = per-deal config, not a platform constant.
  • Bounded trust: v3-32 (NAV deviation cap / breaker / staleness) limits Lambda's NAV moves; money-path immutable (v3-27) → worst case mis-mark, not theft. Off-chain valuation is industry-wide (Centrifuge V2/V3 oracle, Maple/Goldfinch manager).
  • Mixed-capital: a fund-level "% of total" buffer can't be on-chain-enforced from a slice anyway; Lambda applies the waterfall to our pools only.
  • Future (non-destructive): add an on-chain waterfall pool type (Centrifuge/TrueFi-style) alongside if institutional Senior investors demand trustless subordination — no migration.

Docs: 14-decisions v3-39, 04-pool-models.

📅 June 23, 2026 — Epoch duration is create-only in v1; live change deferred to v2 timelock governance (v3-38)

🔁 v3-38 — epoch duration set at pool creation only (no live change)

The epoch refactor shipped setEpochDurationDays as a naked DEFAULT_ADMIN_ROLE setter (no bound / event / timelock / live guard). But epoch_days controls when and how investors exit — more sensitive than reserve% (which is 7d-timelocked) — so a bare live setter conflicts with money-path-immutable.

  • v1: epoch_days is set only at pool creation (folded into the deploy multicall, type-prefilled REVOLVING → 7 / FIXED_TERM → 0). No live-change UI; the setter stays break-glass admin only. No live investor's redemption terms ever change → timelock/custody/disclosure problem sidestepped.
  • Why: tightening liquidity on a live pool = undisclosed redemption gate (investor-protection / regulatory red flag); unbounded N = fund-trapping backdoor breaking the non-custodial exit guarantee (v3-31).
  • Contract hardening — ✅ implemented & tested (pending mainnet deploy + audit): setEpochDurationDays now enforces MAX_EPOCH_DURATION_DAYS = 90 (revert EpochDurationTooLong) + emits EpochDurationChanged + create-only (revert EpochImmutableAfterDeposit once totalLPSupply > 0). Covered by PlatformPoolEpoch.t.sol (4 setEpochDuration cases; 23/23 epoch suite green). Sepolia (6/22) is the pre-hardening build → needs redeploy.
  • v2 (needs audit): if live change is needed, route through the same 7d timelock governance as fund_wallet/reserve%/KYC, direction-aware (tighten = notice + exit window; loosen = lower notice).
  • FE: epoch_days joins the Class-B live-immutable set → admin pool-edit renders it locked + a brief reason (FE-UI-v3 task).

Docs: 14-decisions v3-38, 04-pool-models, 07-redemption, 08-smart-contracts.

📅 June 23, 2026 — Cold-key multisig adopted (Safe): Admin 3-of-5 + separate Pauser 2-of-3 (v3-37)

🔐 v3-37 — Gnosis Safe multisig for cold roles (config-only, no contract change)

Post 2026-06-23 external security review. Non-custody never required multisig (v3-32 immutable money-path), but it's adopted as defense-in-depth.

  • DEFAULT_ADMIN → cold Safe 3-of-5 (governance / lifecycle / role-grant; already timelocked). Closes the "single cold key self-grants roles" concern.
  • PAUSER → a separate cold Safe 2-of-3 (faster emergency freeze; safe because freeze is halt-only + auto-expiring per v3-28 → a rogue Pauser can only grief, not steal).
  • ORACLE stays the hot Lambda key (automation; bounded by fixed destinations + v3-32). YIELD_DEPOSITOR stays partner fund_wallet Safe.
  • No new role / no redeploy — Admin & Pauser are already distinct roles; grant each to a Safe (hasRole is indifferent to EOA vs Safe). A new signer-combination tier would need a new role + redeploy — explicitly not doing that.
  • Thresholds provisional (3-of-5 / 2-of-3); may change on cost/availability. Signers physically separated; avoid 1-of-1. Audit follow-up: Zellic Korea.

Docs: 14-decisions v3-37, 09a-custody, 09-rbac.

📅 June 22, 2026 — FM/admin dashboard stats: per-fund drill-down + shortfall KPI (v3-36)

📊 v3-36 — per-fund stats + shortfall (awaiting partner funding) KPI

Builds on the read-authz layer (FM/admin GET endpoints are fund-scoped server-side).

  • Per-fund stats drill-down: GET /dashboard/stats?fund_id= (admin = any fund, FM = own fund only — server validates → 403 otherwise). Surfaced as a "Fund Stats" section on fund-detail (TVL / Yield / Investors / Pending Redemptions / Awaiting Funding / Pending Yield). Dashboard itself stays aggregate (admin = platform / FM = own-fund). Reuses the existing route (no new infra).
  • Shortfall KPI (count): PENDING_RESERVE redemptions (approved but reserve can't cover → partner must fundRedemption) surfaced as alerts.pending_reserve → dashboard "Awaiting Partner Funding" action item + fund-detail "Awaiting Funding" card (amber > 0). Count only — the $ amount is deferred to the backend persisting the on-chain shortfall (RedemptionPendingReserve / EpochFundingNeeded, already the post-reserve deficit) into the dormant redemption_requests.funding_shortfall, then the amount is a simple SUM. (Requested to backend dev; see Notion v3-26 §15.)
  • FM read-only guards added the same day: hide "Create Distribution" (yield) + Configuration tab read-only for FM.

FE merged (jy/product). Backend (dashboard.get.stats.ts) needs pnpm cdk:deploy.

📅 June 22, 2026 — v3-28 / v3-32 landed in contract (ch/product merge)

🛠 v3-28 + v3-32 — now implemented (code-verified post-merge)

The ch/product contract commit (non-custodial v3: KYC UUPS, epoch, EIP-170 lib) landed both safety items; docs updated to Implemented:

  • v3-28 time-bound freeze: emergencyFreeze() records freezeStartedAt; asymmetric gate (capital-IN blocked whole freeze, value-OUT auto-unblocks after FREEZE_EXIT_WINDOW = 72h); whole freeze auto-expires at FREEZE_MAX_DURATION = 7d; proposeFreezeExtend governance path.
  • v3-32 updateNAV core bound: updateNAV now calls _checkNavBound (circuit breaker + deviation cap, both NAV directions) after the ≤ 1.0 clamp → hot-key write is bounded in core. executeEpoch adds a staleness gate. Closes the v3-29 open item.

Note: an earlier same-day audit (against pre-merge branch state) had flagged both as unimplemented; the merge superseded that. No app-code change — docs (08-smart-contracts, 14-decisions) reconciled to the merged contract.

📅 June 22, 2026 — PlatformPool EIP-170 fit: RedemptionLib extraction

🧱 RedemptionLib — size reduction (behavior-preserving)

PlatformPool exceeded the EIP-170 24,576 B limit (27,877 B) after epoch redemption + non-custodial hardening, so it could not be deployed. All redemption logic (instant request/approve/payout/reject, epoch settlement, instant fallback) was extracted into an external delegatecall'd RedemptionLib, with shared helpers in PoolCommonLib, and all pool state migrated to a single AppStorage Layout struct (PlatformPoolStorage.sol). Libraries are deploy-time linked (immutable — no upgrade path; money-path stays immutable per v3-27). Result: 27,877 → 21,311 B (+3,265 B margin), behavior-preserving (277/277 forge tests green), ABI unchanged (selectors/topic0/getter tuple order preserved → backend Lambdas need no code change). Sepolia redeployed (new PoolImpl + Factory + KYC UUPS proxy — see apps/contract/sepolia.md). Detail: 08 → Implementation Libraries.

📅 June 18, 2026 — Deprecate collateral_type enum (v3-35, soft; completes v3-07)

🏷️ v3-35 — collateral*type enum removed

The collateral_type enum (Fully / Partially / Unsecured) is deprecated via a SOFT transition (2026-06-23 build): backfilled into collateral_description and no longer read by any UI — but the column is kept and only dropped in a later migration once nothing reads it. Collateral = collateral_description (free text) + collateral_ratio (NUMERIC, display-only, may exceed 100%). Rationale: v3-25 already moved the risk signal to the auto-computed Risk Tier badge, so the rigid enum + its ratio-consistency validation add nothing — this obsoletes Pool Create Step 4 Bug 3/4 (strict Partially=0–100% / Unsecured=0). Work (done, soft): migration 0029 (backfill description; enum/column kept) · admin create/edit select→description · admin list/detail badge→description · investor risk-disclosure/holding/portfolio → description · removed VALID_COLLATERAL_TYPES API check. Deferred (hard cleanup): drop enum+column migration · remove investor collateral filter (still on enum). (FE-UI #32)

📅 June 18, 2026 — Non-custody hardening: targeted modularization + exit fallback + NAV bounds (v3-30 / v3-31 / v3-32)

🔐 v3-30 / v3-31 / v3-32 — three non-custody decisions

  • v3-30 — Targeted modularization: core money-path stays immutable; only the KYC contract → upgradeable proxy (UUPS) + timelock, pre-mainnet. Stablecoins use existing setters; oracle/fee/validation are off-chain; updateNAV bounds stay in core. Periphery may gate eligibility, never move funds. ⚠️ KYC gates REVOKED redemption (AML) → mild #5 caveat, mitigated by timelock; legal/audit to confirm.
  • v3-31 — Exit-right fallback (#5): add a permissionless timelocked claimRedemption — after N days unsettled + reserve available + not AML-flagged, any investor self-executes pro-rata at last NAV, no ORACLE approval. approveRedemption re-framed as "speed + AML-hold," not an exit gate. WIND_DOWN-only was insufficient (circular). Benchmark: Maple / Goldfinch.
  • v3-32 — NAV bounds in core + role-admin acceptance: implement updateNAV deviation cap + circuit breaker + staleness in the immutable core (was claimed in docs, missing in code → closes v3-29 open item). Role-admin griefing accepted with single cold key + timelock, no multisig (theft blocked by immutable money-path, griefing bounded by the cap, escalation visible via timelock + v3-31 exit). 2-of-3 cold multisig = optional defense-in-depth.
  • v3-33 — Managed wallet allowed, Aset share = 0: general-investor target → embedded/managed wallet option allowed (email/social, no seed) alongside self-custody, with the constraint that Aset holds zero key shares (3rd-party non-custodial provider; user signs client-side → still non-VASP). ⚠️ a variant where Aset stores/reshares shares = custodial → re-test + licensing.

Updated 14-decisions (v3-30/31/32/33), 09-rbac → Key custody (stale fixes: request.investor, NAV-cap-not-yet-in-code), 09a-custody. Contract work: KYC proxy (v3-30), claimRedemption fallback (v3-31), updateNAV bounds (v3-32) — all pre-mainnet. Checklist: Non-Custodial (Notion).

📅 June 18, 2026 — FM_ACCEPTED removal finalized (v3-34) + unclaimedYield scope

🧹 v3-34 + cleanup

  • v3-34 — FM_ACCEPTED removed (finalizes v3-04): redemption flow = REQUESTED → RECOMMENDED → APPROVED → …; no FM pre-acknowledge step. Cleanup (dev): enum value · /fm-accept endpoint · seed · status-badge · CLAUDE.md flow · 07/15/18 docs.
  • unclaimedYield scope decided: withdrawFees is bounded by the fee portion only — never investor unclaimed yield / principal / reserve. Dev to confirm contract variable (rename to unclaimedFees if clearer). (09a §6)
📅 June 18, 2026 — DB↔on-chain field sync & PATCH enforcement (v3-29)

🔗 v3-29 — on-chain value sync (Class A/B/C)

PATCH currently updates DB only → DB↔on-chain can diverge. Pool fields classified + enforcement rule:

  • A is_paused → PATCH must call on-chain pause()/unpause() (DB-only = security bypass via direct deposit()).
  • B immutable on-chain fields (capacity / penalty / min·max / redemption_type / notice / lockup / maturity / currencies) → PATCH rejected after deploy; DRAFT editable (no contract change; matches Editability-After-ACTIVE).
  • C off-chain-only fields stay DB-only. investment_blocked deprecated — redundant with on-chain is_paused (now wired); removed from invest-eligibility, DB column deprecated.

See 08 → Field Sync + 14-decisions v3-29. Contract change: none (backend wiring + PATCH guard). Impl spec: ch ONCHAIN_VALUE_SYNC_DECISION.md.

📅 June 17, 2026 — Doc consolidation: custody hub (09a) + timelock canonical table

🧹 Consolidation — no new decision

Custody and timelock content was scattered across 04/08/09 and hard to see "at a glance", so it was consolidated into a single SoT:

  • New 09a-custody — 6 non-custody criteria · key model (cold/hot) · money-path invariant (v3-27) · upgrade governance · freeze exit-right (v3-28) · Service Key bounded · wind-down recovery · regulatory basis. (The 6 criteria moved from Notion → vitepress SoT.)
  • 08-smart-contracts → Timelock Configurations = canonical table of timelock values (upgrade/freeze rows added). Other docs cross-ref it.
  • 04/09 keep their feature context + cross-ref to 09a.

Existing decisions (v3-26/27/28) rearranged — no schema / contract / decision change.

📅 June 17, 2026 — Non-custody key governance decided (v3-27 / v3-28)

🔐 v3-27 / v3-28 — single cold key is non-custodial, two gaps closed

  • v3-27 — Money-path non-upgradeable: user fund paths (redemption payout = msg.sender, yield = current holders, withdrawFees bounded to accrued fees → fixed treasury) are excluded from upgrade scope; only periphery (oracle / fee) is upgradeable, behind a timelock. Current contracts are already non-upgradeable (Clones fixed implementation), so this ratifies the design; multisig not required (single cold key + timelock suffices).
  • v3-28 — Emergency freeze exit-right: instant freeze kept for emergencies but never indefinite — a full-freeze that blocks exit lasts max 72h → redemption auto-allowed afterward; other freeze window 7 days; extend / unpause only via governance + timelock (asymmetric, Compound-style "exit never blocked").

Updated 09-rbac → Key custody (Open → Decided). Contract work items in Claude-Plan/Active/non-custodial-money-path-implementation.md. Contract change for v3-28 (freeze max-duration / fallback); v3-27 needs no change (already immutable).

📅 June 16, 2026 — fund_id required on every pool; Aset-direct pools removed (v3-26)

🏦 v3-26 — Every pool is fund-linked (no Aset-custody direct pools)

Every pool must have a fund_id (partner fund). The Aset-direct pool config (fund_wallet = Aset treasury, no fund_id) is removed — in a direct pool 90% of deposits route to Aset treasury → Aset holds investor funds → custodial, breaking the non-custody / VASP-avoidance posture. Build target is invest-through-Aset (full on-chain), not the Joob read-only hybrid.

  • Code: pools.post.create now rejects a missing fund_id; admin pool-create (step-basic-info + step-1 validation) requires fund selection.
  • DB: pools.fund_id NOT NULL deferred (clean existing no-fund_id rows first) — enforced at app layer for now.
  • Docs updated: 04-pool-models (fund_wallet/fund_id), 02-core-concepts (no "Aset treasury for direct pools"), 11-db-schema.
  • ⚠️ Not the non-custody guarantee: that lives in the contract layer (immutable money-path, independent multi-sig + timelock, fund_wallet ≠ treasury) + Legal — see 14-decisions v3-26. fund_id required is a product-level constraint.

Contract impact: none (fund_id is off-chain only).

📅 June 16, 2026 — Doc cleanup: removed stale D+7 refund / Receipt NFT / Escrow leftovers

🧹 Cleanup — no new decision

Removed stale references that contradicted already-shipped decisions (v3-03 refund removal, v3-11 Escrow absorption):

  • D+7 auto-refund (v3-03): deleted the "Refund Timer" trigger (12-triggers), the "D+7 Auto-Refund Safety Net" runbook (13-operations), and the deposits.scheduler.auto-refund + POST /deposits/{id}/refund entries (15-api-reference). No refund Lambda/scheduler exists in code.
  • Receipt NFT (v3-03): removed RECEIPT_BURN_FAILED + REFUNDED rows and the → REFUNDED deposit status-flow (18-failure-types).
  • PlatformEscrow (v3-11): contract registry now shows it removed/absorbed (was "Under Review"); active-contract count corrected to 4 (01-overview).

Consolidated (anti-drift): penalty-type enumeration trimmed from 12-triggers → canonical 04; lifecycle_status per-state details + transition block trimmed from 04 → canonical 10-status-machines. Different-angle content (07 calc / 20 Joob example / 02 intro / 03 KYC flow) left intact — not true duplication.

⚠️ Not touched (deprecated-but-still-live in code): redemption_status.FM_ACCEPTED (enum + /redemption-requests/{id}/fm-accept endpoint + seed) and escrow_status column — flagged for a separate cleanup decision.

Documentation-only — no schema / contract / decision change.

📅 June 16, 2026 — Doc clarification: consolidated "Editability After ACTIVE" view

🔧 Clarification — no new decision

Added an "Editability After ACTIVE" subsection to 04-pool-models → Mutability Matrix. The existing Mutability Matrix grouped fields by change mechanism (immutable / timelock / instant), so the question "once a pool is ACTIVE, what locks?" required cross-referencing several category tables.

  • New table + quick-read box organized along the DRAFT → ACTIVE axis. Highlights that lockup_days, maturity_days, penalty_type, fx_rate_source are editable in DRAFT but lock at the ACTIVE transition (not at deploy).
  • 🟡 timelock fields (fund_wallet, reserve_percentage, kyc_level_required, kyc_jurisdiction_whitelist, redemption_gating_pct) and 🟢 always-editable fields (yield_frequency, net_yield_fee_config, collateral_*, allow_rollover, is_paused, is_emergency_frozen) restated from the ACTIVE perspective.

Reorganizes existing mutability rules for clarity — no schema / contract / decision change.

📅 June 16, 2026 — Pool Risk Tier badge (Low/Med/High), receivables-first (v3-25)

🟢 v3-25 — Pool Card Risk Tier (replaces collateral_type badge)

The deprecated collateral_type badge (v3-07) is replaced by an auto-computed Low / Med / High risk tier on the pool card + detail (investor and admin). Receivables-first model: we tier by structure (tranche) + portfolio health, not collateral ratio (like Maple / Goldfinch / Centrifuge). FX excluded (operator-managed); NPL not used directly (already in nav_per_token).

  • Thresholds (confirmed): NPL <2 / 2–5 / >5% · reserve baseline 10% · NAV <0.90 / 0.90–0.999 (lower = worse). Precedence High → Med → Low. Cadence: event-driven for lifecycle/freeze/NAV, monthly for NPL/DPD, hysteresis to avoid flapping.
  • Phase 1 (implemented, FE): lifecycle_status + is_emergency_frozen + nav_per_token + reserve_percentage. RiskTierBadge replaces CollateralBadge; LIST_SELECT gains is_emergency_frozen + reserve_percentage.
  • Phase 2 (⏳ pending): tranche_role (v3-14, FE not wired) + partner NPL/DPD — depends on Joob API 2nd data request (DPD ratio denominator; concentration/tenor/utilization optional) + total_rni gross/net clarification. Equity Buffer (operator NPL≤5% first-loss) has no DB field → proper support implies on-chain operator first-loss (next cycle); display-only until then.

See 04-pool-models → Risk Levels and 14-decisions v3-25. No schema / contract change (Phase 1 uses existing columns).

📅 June 16, 2026 — Doc clarification: multiple USD-backed stablecoins supported (single recommended)

🪙 Clarification — accepted stablecoins (no new decision)

Aligned 04-pool-models → accepted_currencies to current code: the currency enum supports USDC / USDT / DAI (USD-backed only), and a pool's accepted_currencies array may list several. USDC is the default/recommended and one stablecoin per pool is still recommended, but multi-stablecoin support is real (not USDC-only). Bridged variants (e.g. USDC.e) remain excluded (depeg risk).

Documents existing behavior — no schema / contract / decision change.

📅 June 15, 2026 — No investor FX disclosure; operating_currency is backend-only (v3-24)

💱 v3-24 — FX disclosure dropped (USD-in / USD-out)

Investors deposit and redeem in USD (USDC) and never hold the underlying local currency, so they bear no direct FX exposure. The investor-facing FX risk warning / banner is dropped.

  • operating_currency / fx_rate_source are kept as backend NAV-conversion data only — used to convert a non-USD fund's NAV (e.g. Joob = IDR) to USD for display. Not an investor-facing disclosure.
  • Removed the "FX risk" row from the pool risk-disclosure box and clarified the operating_currency spec in 04-pool-models.
  • FE: the FX banner work item is Dropped (was previously parked as "API-first").

No schema / no contract impact — display/UX decision only.

📅 June 12, 2026 — Read-layer authorization split out of WO-4 (v3-23)

🔐 v3-23 — Read endpoints have no backend authz; carved into its own spec

While implementing WO-4 (FM panel) it surfaced that no GET endpoint has any backend authorization — there is no API Gateway authorizer and the list/detail/dashboard handlers aren't wrapped in withAuth/withRole, so sensitive data (/deposits, /redemption-requests, /yield-distributions, …) is effectively world-readable, and these handlers are shared with the investor web app.

  • Decision: this is bigger than FM and touches the investor app, so the read-layer authz is split out of WO-4 into a standalone backend spec (read-authz-backend-spec.md) — a 3-role data model (investor = own, FM = fund-scoped, admin = all) applied to read endpoints.
  • WO-4 keeps: the write boundary (already guarded) + email-keyed-membership hardening + all FM frontend work (perms, route guards, read-only UI, PII hiding).
  • Until the read-authz spec ships: FM read-scoping is frontend-only (not a real boundary). Server-side investor_email omission for FM also moves to that spec. See 09-rbac → Fund Manager Panel Scoping.

No schema / no contract impact. Read-only authz layer.

📅 June 12, 2026 — Pool Updates / Announcements feed planned (v3-22)

📣 v3-22 — Per-pool Updates / Announcements (planned — WO-6)

Decision to add a per-pool Updates / Announcements feed so FM/Admin can post human-written notices (status updates, material events, distribution notices) that the pool's investors see. Grounded in RWA-platform convention: Centrifuge (structured pool disclosure + governance-forum issuer notices, e.g. "Notice of Upcoming Pool Parameter Restructuring") and Maple ("protocol updates" for default/recovery progress). Today Aset has only pool_documents (files), notification_logs (email), and activity_events (system log) — no curated, manager-written feed.

  • New table pool_updates (pool_id, author, title, body, category INFO/IMPORTANT/MATERIAL_EVENT, published_at) — distinct from activity_events (system log).
  • Permissions: Admin = all pools; FM = own-fund pools only (requireFundAccess, per WO-4). Investors read their pool's published updates.
  • Optional: notify the pool's investors on IMPORTANT/MATERIAL_EVENT; auto-generate an entry for lifecycle/material events (impairment, wind-down, NAV writedown, yield funded — ties into v3-20) alongside manual posts.
  • English-only, PII-free. Tracked as WO-6 (spec parked, not started).

Update (2026-07-02): SHIPPED. Built as migration 0012 (pool_updates + pool_update_revisions) with 4 endpoints; the RBAC model (Operator opt-in, FM own-fund/own-posts, soft-delete retention, MATERIAL_EVENT revision audit) is formalized in v3-57.

📅 June 12, 2026 — FM panel scoping (WO-4) decisions locked: PII rule, route guards, backend scope (v3-21)

🟣 v3-21 — Fund Manager scoping (WO-4) open items resolved

Finalizes the follow-ups left open in v3-19 for the FM panel work order (WO-4). Full per-page spec: RBAC → Fund Manager Panel Scoping.

  • PII rule (legal-checked): FM sees investor NAME + WALLET ADDRESS; EMAIL hidden on all FM pages (Redemptions table / CSV export / detail). Grounded in data-minimization / need-to-know (GDPR Art. 5(1)(c), PIPA Art. 16) + controller/processor "essential means" (EDPB 07/2020). FM confirmed a processor / scoped role (no own KYC/AML duty) → rule holds firmly. Enforce server-side (omit field from FM responses), not just UI. Wallet address = personal data but recipient-relative (EDPB 02/2025, Breyer, EDPS v SRB).
  • Route guards = centralized + shared map. Extract the route→pageKey map out of the sidebar into a shared module; both the sidebar (nav) and protected-layout (URL guard) consume it. Param routes (/pools/:id) use prefix matching.
  • Backend fund-scoping is IN WO-4 (the real boundary). requireFundAccess() exists but is missing on several handlers — add to: pools create/patch/delete, nav-changes propose/activate/cancel, pools freeze/impairment/wind-down/governance, redemption create, yield-claims.
  • poolType (AS_POOL/FUND_POOL) cleanup → separate refactor WO (not WO-4), to keep this diff focused. Replace with fund_id presence checks.
  • Fund membership stays email-keyed (no admin_user_id FK — keeps invite-by-email onboarding). Doc corrected (it wrongly said admin_user_id). Two hardening rules added: (a) normalize email to lower(trim()) on all fund_members/admin_users writes + in requireFundAccess; (b) on FM offboarding, deactivate that email's fund_members rows so a recycled email can't auto-inherit fund access.

No schema / no contract impact — RBAC + scoping + UI only. ⚠️ Pending: Korean counsel review of PIPA application; AML/CDD·FATCA/CRS minimum-PII confirmation.

📅 June 12, 2026 — Yield is manual-only; AUTO removed + D-n countdown (v3-20)

⏱ v3-20 — Yield trigger removed (claim-based / manual only), distribution dates shown as D-n

v3.0 already unified yield to claim-based / manual-trigger (01-overview), but AUTO remnants lingered in the admin UI and DB. This cleans them up and improves the date display.

  • yield_trigger field removed from the admin UI (pool create/edit forms, pool-detail config + yield-settings tabs, the yield-distribution "Trigger" column). There is no AUTO/MANUAL choice — every pool is manual claim. FE coerces any legacy AUTO row to MANUAL.
  • Yield distribution date display (admin & FM)getDDay / <DDayBadge> in pools list, pool-detail yield tab, yield overdue bar:
    • ≤ 30 daysD-n countdown (D-DAY today; amber within 7 days). > 30 days → absolute date (a far-out countdown is hard to parse). Overdue → red D+n.
    • Estimated vs Confirmed (design — mirrors equity dividend calendars): an estimated next-date renders muted/~; a confirmed date renders solid. <DDayBadge confirmed> supports this, but the data is not yet wired (see TODO).
    • ⚠️ Backend TODO — data wiring (both decisions below resolved 2026-06-12; only the estimated yield amount formula stays open):
      1. Estimate anchor = last_distribution_date + interval (NOT start_date; reflects real cadence drift — a 30-day pool may pay in 27 days). start_date + interval only as the first estimate before any distribution exists. Backend computes (FE can't reach the distribution records). Also expose last_distribution_date (= MAX(yield_distributions.distributed_at)).
      2. Confirmation mechanism = Estimated → Funded (2-state, fully automatic; decided 2026-06-12). Estimated (muted/~, auto = last_distribution + interval) → Funded (solid, automatic when the partner's depositYield for the period lands on-chain). No manual "Scheduled" step (deferred). Backend signal needed: whether the upcoming period is funded. (The estimated batch yield amount formula is a separate, later track — undecided for now.)
  • FM-editable yield fields (from v3-19) are now yield_frequency + custom interval + next-distribution memo only — yield_trigger is no longer an editable field.
  • DB cleanup: migration 0010_drop_yield_trigger drops pools.yield_trigger + the pools create/update Lambdas no longer reference it. ⚠️ Apply pending — backend runs 0010 and redeploys the Lambdas together. yield_distributions.distribution_type already defaults to 'MANUAL' (left as-is). See 11-db-schema.

FE-only change + backend migration TODO. No contract impact (contract never had a trigger/frequency field — yield is depositYielddistributeYieldclaimYield).

📅 June 12, 2026 — Fund Manager panel = role-scoped (v3-19)

🟣 v3-19 — FM is a role-scoped single panel, not a separate /fund-admin

Resolves the doc↔code divergence on the Fund Manager surface. Decision: keep the single admin app, scope it by role (no separate /fund-admin panel was/will be built). See RBAC → Fund Manager Panel Scoping for the full per-page spec.

  • Panel: FM signs into the same admin app; the sidebar is filtered by role and all data is scoped server-side to the FM's fund(s) (fund_memberspools.fund_id). The earlier "Panel: /fund-admin" line is superseded.
  • FM permission keys (target): dashboard, deposits, redemptions, yield, pools, fund_managerskyc excluded. (Expands the prior code default of redemptions/yield/fund_managers to add a fund-scoped dashboard + read-only pools/deposits.)
  • Read-only for FM: Pools list, Pool detail (Edit/Delete + on-chain Operations tab hidden), Deposits (retry buttons hidden).
  • Group 2 decisions: Deposits → visible, fund-scoped, read-only. Pool edit → yield-settings fields only (yield_frequency, yield_trigger, custom interval, next-distribution memo); every other field Admin-only.
  • PII: Redemptions hides investor email for FM.
  • Hidden + route-guarded for FM: KYC, Admin Settings, Audit Log.
  • Security follow-up: routes are currently hidden via sidebar only → add route-level hasPermission guards (URL-bypass fix). Backend per-endpoint fund-scoping is the authoritative boundary (filter list endpoints, 403 out-of-scope ids).

No schema / no contract impact — RBAC + scoping only.

📅 June 1-2, 2026 — v3.0 Refinements (v3-11 through v3-18)

🔧 v3-11 ~ v3-18 — Architecture refinements + supersessions

Follow-up decisions after initial v3.0 design (May 29). Some supersede earlier dimensions; others fill gaps discovered during implementation planning.

  • v3-11: PlatformEscrow absorbed into PlatformPool — 10/90 split inlined; contract count 5 → 4
  • v3-12: Wind-down unified with NAV mechanism — claimWindDown() removed, standard redeem() handles both. New IMPAIRED lifecycle state (Maple-pattern) between ACTIVE and WIND_DOWN
  • v3-13: Automated NAV writedown via Joob DPD API — OJK schedule (5/15/50/100% by DPD bucket), joob-dpd-sync Lambda daily cron, pending Joob policy confirmation
  • v3-14 (supersedes v3-09): Tranche via linked SINGLE pools — tranche_group_id UUID + tranche_role ENUM (SENIOR/MEZZANINE/JUNIOR) columns. Max 3 pools per group. Aset Lambda applies loss waterfall during NAV computation. Smart contract impact: zero (each pool stays a standard SINGLE)
  • v3-15 (supersedes v3-08): nav_data_source dimension removed. All pools route through Aset oracle Lambda — partner APIs provide raw data, Aset always processes (required for tranche waterfall + write-off schedule enforcement)
  • v3-16: Reserve consumption integrated into updateNAV(newNav, reserveConsumed) — atomic call from Aset Lambda. Reserve decreases on loss absorption (within buffer NAV stays $1.00) and on redemption payout. Race-free single-event audit trail. ⚠️ The loss-absorption half is superseded by R8 (v3-109) — the reserve never absorbs loss, reserveConsumed is always 0 and the contract now reverts on anything else. The atomicity rationale and the ABI stand; reserve still moves on redemption payout
  • v3-17: Cross-VASP KYC strategy — SumSub Reusable KYC (Track 1, if partner uses SumSub) or fast re-KYC with returning-user flow (Track 2, fallback). Recipient-level checks always re-run for regulatory compliance. Travel Rule (Notabene/Sygna, IVMS101) handled as separate transaction-level integration
  • v3-18: On-chain vs off-chain data boundary — minimum on-chain config (settlement state + reservePct + fundWallet + kycContract). Adds kycLevelRequired and kycJurisdictionWhitelist on-chain to prevent regulatory bypass. Early-exit penalty (RedemptionConfig) is also on-chain — contract computes/applies it (penalty SoT on-chain). Other config (tranche, lifecycle, marketing) stays in DB; Lambda enforces

Schema additions: tranche_group_id UUID + tranche_role ENUM (v3-14), loan_writeoffs table (v3-13), IMPAIRED lifecycle status value (v3-12)

Schema removals: nav_data_source ENUM (v3-15), tranche_structure ENUM (v3-14), escrow_address column (v3-11)

Smart contract impact: PlatformEscrow.sol deleted (v3-11). PlatformPool gains proposeImpairment()/executeImpairment()/cancelImpairment() (v3-12), inline 10/90 split in deposit() (v3-11), updated updateNAV(newNav, reserveConsumed) signature (v3-16), KYC level/jurisdiction enforcement in _validateKYC() (v3-10 + v3-18). claimWindDown() removed — standard redeem() handles wind-down (v3-12).

Numbering convention: D-v3-XXv3-XX (shorter)

May 2026 2 entries

📅 May 29, 2026 — v3.0 Dimension Model (initial design)

🧩 Config-driven pools — binary AS_POOL/FUND_POOL deprecated

Core shift: pool_type enum replaced by ~24 orthogonal dimensions (fund_wallet, reserve_percentage, maturity_model, kyc_level_required, etc.). Same Pool contract + same flow for every partner — only configuration differs.

Decisions v3-01 through v3-10 (14-decisions):

  • v3-01: pool_type binary → ~24 dimensions
  • v3-02: LP always platform-issued (FUND_ISSUED deprecated)
  • v3-03: PlatformReceiptNFT removed — deposits atomic (USDC + LP mint + fund_wallet split in one tx)
  • v3-04: PENDING_RESERVE replaces FM_ACCEPTED in redemption flow
  • v3-05: Two-level pause — is_paused (soft, deposits only) + is_emergency_frozen (hard, all activity)
  • v3-06: Emergency wind-down (60-day silence trigger + 30-day timelock). Only Pool reserve (~10%) recoverable on-chain
  • v3-07: collateral_type enum → collateral_description text + collateral_ratio numeric
  • v3-08 nav_data_source dimension — superseded by v3-15 (dimension removed)
  • v3-09 tranche_structure dimension — superseded by v3-14 (tranching via linked SINGLE pools)
  • v3-10: Per-pool KYC — kyc_level_required (KYC/KYB) + kyc_jurisdiction_whitelist (ISO codes)

Schema changes (11-db-schema): Added 16 new pool columns. Added maturity_model enum. Deprecated: pool_type, escrow_model, yield_distribution_model, lp_issuance_model, risk_tier, collateral_type. Receipt/FM columns marked deprecated.

Smart contracts: PlatformReceiptNFT removed. PlatformPool gains proposeWindDown() / executeWindDown() / fundRedemption() / setEmergencyFreeze(). PlatformPoolFactory unified with on-chain registry.

Joob mapping (20-joob-pool-config): Two scenarios documented — active investors pool (Ethereum mainnet, USDC, nav_data_source=ASET_INTERNAL) and legacy display-only pool (Kaia, existing RPS).

Unified across all pools:

  • Yield: claim-based, yield_trigger = MANUAL only (auto-distribution removed)
  • Escrow: always SMART_CONTRACT (legacy variants removed)
  • Reserve: reserve_percentage per pool, distinct from Junior tranche
  • Risk: no risk_tier enum — replaced by transparent data points
📅 May 31, 2026 — Phase 2.5: Universal Holder Verification (5-State Model)

🪪 5-State holder model — works across read-only/actual-investment/secondary

Decision: Introduce 5-state holder model (A/B/C/D/E) that unifies three scenarios into one state machine — read-only display (Joob legacy), actual investment (Aset direct/Joob new), and secondary market (Tokocrypto). Same model, different entry/exit mechanisms.

States (21-holder-verification):

  • A. Verified Investor — SBT minted + active position via Aset deposit
  • B. Verified, No History — SBT minted, no current position (yet/anymore)
  • C. Unverified Holder — holds LP/RPS on-chain but no SBT (e.g., received via transfer from another wallet)
  • D. Identity Only — KYC submitted, not yet approved or SBT mint pending
  • E. Unregistered Visitor — no KYC, no position, just browsing

3-Layer architecture:

  • L1 Ownership — on-chain balanceOf() (LP token, RPS, future tranche tokens)
  • L2 IdentityPlatformKYCSoulbound SBT attributes (level, jurisdiction, expiry)
  • L3 Entitlement — DB row in portfolio_positions linking wallet → pool → effective balance

Schema impact: portfolio_positions gains source ENUM (DEPOSIT/TRANSFER_IN/SECONDARY_PURCHASE/LEGACY_SEEDED), entry_price, entry_tx_hash, last_reconciled_at.

Why one model for three scenarios: Avoids parallel codepaths for "Joob read-only" vs "real Aset deposits" vs "Tokocrypto secondary". State C handles every "wallet holds token but no Aset record" case identically — whether legacy RPS transfer, off-platform secondary sale, or recovered wallet.

March 2026 5 entries

📅 March 20, 2026 — Per-Pool Stablecoin Configuration

💱 Per-pool stablecoin selection + docs correction

Per-pool stablecoins: Each pool defines which USD-backed stablecoins it accepts (USDC, USDT, DAI). Stored in pools.accepted_currencies (currency[] array, default '{USDC}'). Admin selects at pool creation; immutable once ACTIVE.

Docs fix: pool_accepted_currencies junction table in schema docs was incorrect — actual implementation uses pools.accepted_currencies array column. Docs updated to match DB.

Invest flow: Investor selects stablecoin if pool accepts multiple. Deposit records currency used. Contract addresses are chain-specific (not pool-specific).

Admin UI: Platform-level stablecoin config removed (was localStorage-only, never backend-connected). Replaced by per-pool selection in pool create wizard.

Smart contracts: addStablecoin() / removeStablecoin() documented for on-chain stablecoin management.

📅 March 17, 2026 — schema v2.17 R18

🔄 compound() → reinvest() rename + Reinvest V1 policy

Rename: compound()reinvest() across contracts, API, and docs. /yield/compound/{distributionId}/yield/reinvest.

BD5 Decided: Manual Reinvest V1 — investor clicks "Reinvest" CTA, calls pool.reinvest(totalYield) on-chain → new LP minted. Full yield only (no partial), same-pool only, lockup exempt.

New fields: min_reinvest_amount NUMERIC DEFAULT 50 on pools, is_reinvestment BOOLEAN DEFAULT false on deposits.

📅 March 14, 2026 — schema v2.15 R17

🗂️ Pool Categories → dynamic table

pool_category enum deprecated → category TEXT → FK pool_categories(name). Notion-style select UI. pool_categories table added with GET/POST /pool-categories endpoints.

📅 March 12, 2026 — schema v2.14 R16

🏦 Non-Custodial Architecture + 10 Gap Decisions Resolved

Architecture: All funds flow through Pool Smart Contracts. Aset as facilitator, not custodian. No VASP license required.

Deprecated: escrow_model (always SMART_CONTRACT), yield_distribution_model (always MANUAL_CLAIM), risk_tier (replaced by data-point approach).

New fields: collateral_type, collateral_ratio, yield_frequency, yield_trigger, allow_rollover, next_yield_due, yield_overdue.

Contract: PlatformEscrow merged into PlatformPool. New functions: claimYield(), reinvest(), mintLP(), fundRedemption(), distributeYield().

Brand: EF_POOL → AS_POOL, EMERGEFI_ISSUED → PLATFORM_ISSUED, EMERGEFI → PLATFORM (transfer_source).

Gap decisions: Risk transparency (Gap 1), Pool creation fields (Gap 2), Yield claim-based (Gap 3), Escrow release = LP mint (Gap 4), KYC RETRY/FINAL (Gap 5), FM onboarding (Gap 6), Early penalty per-investor (Gap 7), TVL cap (Gap 8), Notification matrix (Gap 9), Audit retention (Gap 10).

📅 March 2026 — schema v2.13 R15 (Admin Platform Release)

📦 Full schema rebuild for admin platform

v2.13 R15 — Detailed Changelog

Table Renames

  • issuersfunds
  • issuer_adminsfund_members
  • pending_receiptsdeposits

Column Renames

  • pool_statuslifecycle_status (6-state enum)
  • fund_walletpool_wallet
  • issuer_idfund_id
  • investment_blocked — restored with new semantics (TVL cap, not NAV-based)

New Tables (11)

  • admin_users, admin_user_permissions, admin_sessions
  • fund_pool_assignments, pool_accepted_currencies, pool_tvl_history
  • yield_distribution_investors
  • notification_logs, notification_preferences
  • platform_config, activity_events

New Enums (13 added to existing 6)

  • pool_type, lifecycle_status, escrow_model, yield_distribution_model, lp_issuance_model
  • penalty_type, yield_status, sbt_status, reject_type
  • notification_channel, notification_status, notification_failure_type, transfer_source

Removed Tables (3)

  • platform_admins — merged into admin_users
  • fund_manager_assignments — replaced by fund_members
  • kyc_submissions — KYC fields merged into users table

Breaking Changes

  • LP issuance: AUTO/MANUAL → FUND_ISSUED/PLATFORM_ISSUED
  • Redemption: 2-stage admin approval → pool_type-specific flows (4 variants)
  • Redemption status: APPROVED legacy removed → single-stage (Admin approve), FM_ACCEPTED/FAILED added
  • Fund status: 3-state (ACTIVE/PAUSED/OFFBOARDED) → 2-state (ACTIVE/INACTIVE)
  • Deposit: receipt_status → deposit_status (5-state)
  • Re-added investment_blocked BOOLEAN DEFAULT false to pools — no longer NAV-based. Now auto-set true when TVL >= capacity. Manual admin toggle via is_paused remains separate.

February 2026 1 entry

📅 February 2026 — 5 updates

✅ Feb 20, 2026 — PD1–PD4 decided + Gap report fixes (v6)

Oracle decisions finalized: PD1 integrated into PlatformPool, PD2 ORACLE_ROLE, PD3 Aset-operated, PD4 no fallback (admin pool pause for emergencies). Added ORACLE_ROLE to contracts. 13 missing DB fields added. yield_distributions placeholder table added. NAV Update status machine added. 10+ planned API endpoints. Removed NAV Emergency Override. Phase 3 Redemption added to Investment Lifecycle.

🔄 Feb 19, 2026 — Product spec sync + safety bounds simplified

PD6 (NAV Safety Bounds) removed — no floor, no per-tx limit, NAV capped at $1.0 max only. PD1 recommendation updated: Integrated into PlatformPool is now recommended over separate oracle contract. Tech stack verified from GitHub repo. API endpoints: 15 built, rest planned.

🔄 Feb 19, 2026 — Decisions System Overhaul (Batch 5)

Upgraded DECISIONS data model to BD1–BD6 + PD1–PD5 with dependsOn, blocks, referencedIn schema. Merged Business Decisions + Product Decisions into unified Decisions page. Side panel now shows dependency graph.

🗂️ Feb 11, 2026 — Docs Site Restructure (Batch 4)

Converted horizontal tab navigation to sidebar navigation docs site. Added: Overview & Architecture, Core Concepts, Smart Contracts, API Reference, Decisions, Timeline, and Changelog sections. Split Writedown & Redemption into two separate sections. Added decision badge system with slide-out panels.

📐 Feb 15, 2026 — NAV Model Migration (v5)

Replaced Principal Factor (PF) multiplier with NAV (nav_per_token) as the single loss indicator. Investment is no longer auto-blocked when NAV < $1.00. New investors pay fair current price. Writedown formula updated throughout. NAV changes no longer auto-block investment — only is_paused (manual) and investment_blocked (TVL cap) block new deposits.

January 2026 1 entry

📅 January 2026 — 2 updates

👥 Jan 2026 — RBAC Update: Fund Manager Role Added (v4)

Added Fund Manager as a third role (active fund-scoped operations). Clarified Admin vs Operator split. Updated permission matrix.

⏳ Mar 2026 — Single-Stage Redemption (v4)

Redemption simplified to single-stage: Admin approves → reserve check → payout. Operator recommend step removed (merged into approveRedemption). FIFO queue enforced. NAV snapshot locked at request time.

December 2025 1 entry

📅 December 2025 — 1 update

🚀 Dec 2025 — Initial Backend Logic Map Published (v1)

First version with Investment Lifecycle, Status Machines, Database Schema, and Business Decisions. Deployed to Vercel via GitHub integration.