Skip to content

Status State Machines

🚧 In Development

This page reflects v3.0 status flows. Implementation in progress. FM_ACCEPTED was removed (v3-34); epoch redemption adds QUEUED / PARTIALLY_FILLED (v3-26).

All status fields and their valid transitions across the system. v3.0 unifies many flows — same logic for all pools regardless of configuration.

Deposit Status deposits.status

Unified deposit flow in v3.0. Aset always mints LP at deposit time.

PENDINGPROCESSINGCOMPLETED

PROCESSINGFAILED (transfer or mint error)

🟢 v3.0 unified flow

USDC enters Pool Contract → LP minted to investor (Aset's PlatformLPToken) → reserve retained → the partner remainder sent to fund_wallet → COMPLETED.

All in one transaction. No Receipt NFT. No D+7 refund mechanism.

Status Definitions

StatusDescription
PENDINGDeposit transaction submitted, awaiting confirmation.
PROCESSINGBlock confirmation in progress (rare; usually atomic with PENDING).
COMPLETEDLP minted, reserve retained, fund_wallet received its share.
FAILEDTransfer or mint error. failure_type + error_message logged.

Deprecated (v2.x → v3.0)

  • REFUNDED status (D+7 mechanism) — Removed. No refund flow in v3.0. Enum value dropped from deposit_status in migration 0049.
  • AS_POOL/FUND_POOL split flows — Removed. Unified flow.
  • Receipt NFT timeline (PENDING → MATCHED) — Removed.

Existing data with deprecated statuses remains; new deposits use only PENDING/PROCESSING/COMPLETED/FAILED.

KYC Status users.kyc_status

NOT_STARTEDIN_REVIEWAPPROVED

IN_REVIEWREJECTED

Status Definitions

StatusDescription
NOT_STARTEDNo verification on record. Includes a holder who opened the SDK and submitted nothing — that only records kyc_level.
IN_REVIEWThe applicant was submitted and is awaiting a verdict.
APPROVEDKYC passed, SBT minted on-chain.
REJECTEDFailed verification. See reject_type (RETRY / FINAL).

IN_REVIEW has one writer: kyc/enter-review.tsmarkApplicantInReview, fed by SumSub's applicantPending / applicantOnHold webhook or by POST /kyc/sync reading the applicant's reviewStatus back. Issuing an SDK access token does not write it — doing so shipped a lockout, since the status card's PENDING branch offers no way back in and no sweep can clear a status SumSub never reported. See KYC & Identity → KYC Status Flow.

Redemption Request Status redemption_requests.status

Two flows, selected per pool by epoch_duration_days (see Redemption). redemption_status enum (current, v3-34): REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED.

Instant Flow (epoch_duration_days = 0)

Settles inside the request transaction, with PENDING_RESERVE as the shortfall state for partner coordination. No admin approval step (v3-82).

REQUESTEDPROCESSINGCOMPLETED

REQUESTEDPENDING_RESERVEPROCESSINGCOMPLETED (when partner sends funds)

REQUESTED / PENDING_RESERVEREJECTED · PROCESSINGFAILED

🟢 Instant flow

  1. Investor calls requestRedemption → NAV snapshot + penalty computed, and the same transaction checks the reserve against the gross payout.
  2. Reserve covers it → LP burned directly from the investor, USDC paid, penalty routed to fund_wallet, COMPLETED — no escrow, no queue, nothing for an admin to do.
  3. Short → LP escrowed, status PENDING_RESERVE, RedemptionPendingReserve emitted with the shortfall. The partner's fundRedemption auto-settles the request the moment the balance covers it.
  4. Two manual paths remain, neither of them a gate: approveRedemption is an optional settle for a PENDING_RESERVE request the reserve later grew into (it reverts on a REQUESTED one), and claimRedemptionFallback is the permissionless exit after the notice period. Reject is the only genuine admin decision.

Epoch Flow (epoch_duration_days > 0, v3-26)

Requests batch into epoch windows; settled pro-rata at the deadline, then pulled via claimRedemption.

🔁 The engine redesign shipped — these are the statuses it produces

v3-91 / v3-93 are merged, so the flow below is current: requests are accepted only inside the request window (RequestWindowClosed outside it), demand freezes at the cutoff, settlement happens at the cycle's funding date and debits the filled gross into the redemptionCommitted scalar (not the per-epoch pot v3-91 first specified — withdrawn, v3-100), fills carry demand before new, and yield accrual runs to settlement, not to the request (v3-131 (3), shipped 2026-08-20 and on chain 2026-08-21 — new pools only, and no pool has been created from the new factory yet). See 07-redemption → Model B.

Two behaviours are shipped (v3-105): a cancel with an unclaimed fill must claim first (ClaimBeforeCancel, RedemptionLib.sol:626), and previewEpochClaim (PlatformPool.sol:317) serves the filled/unfilled split — deployed to dev 2026-08-04 (factory 0xE1E2E974…DA90 → pool implementation 0x27D9948F…f968, commit 0abe168). ⚠️ New pools only: pools are Clones with the implementation fixed at creation, so every pool created before that deploy still runs the old one, so on those the cancel still returns the whole position and the view reverts.

The schedule install path is now complete end to end (v3-107): the caller exists (pools.post.epoch-schedule.ts), the funding-date provenance columns are applied to dev (migration 0111), and setEpochSchedule / setEpochFundingDate / setEpochSettleAfter are on the implementation deployed 2026-08-04. ⚠️ But setEpochSchedule is create-only, and existing pools were cloned from the old implementation — so pools created before that deploy cannot be anchored at all and keep running the legacy lazy clock with no window.

REQUESTEDQUEUEDCOMPLETED (fully filled + claimed)

QUEUEDPARTIALLY_FILLED → … → COMPLETED (partial fills across epochs, remainder rolls over until fully claimed)

QUEUED / PARTIALLY_FILLED → (cancel: LP returned, epochTotalDemandLp decremented)

Admin holdRequest/releaseRequest can pull an anomalous request (or epoch) out of automatic settlement; held requests roll to a later epoch or await an admin decision (releaseRequest → re-include, or REJECTED).

🟢 Epoch flow

  1. Investor requests → LP locked, enrolled into currentEpochId, status = QUEUED (no NAV snapshot)
  2. At deadline, executeEpoch() computes fillRatio + settlement NAV (O(1), no money movement) — 100% automatic unless an anomaly is held
  3. Investor (or keeper, claim-on-behalf) calls claimRedemption() → filled portion paid, remainder → PARTIALLY_FILLED rollover
  4. Once remainder == 0 → COMPLETED

Status Definitions

StatusDescription
REQUESTEDInvestor submitted request. Instant: nav_at_request locked. Epoch: enrolling. LP transfer-disabled.
QUEUED (epoch)Enrolled in an epoch window, awaiting settlement. NAV deferred to settlement.
PARTIALLY_FILLED (epoch)Pro-rata partial fill claimed; remainder rolled to next epoch.
PENDING_RESERVE (instant)Reserve insufficient; awaiting partner top-up via fundRedemption.
PROCESSINGFunds being sent.
COMPLETEDUSDC received by investor. payout_tx_hash recorded.
REJECTEDRequest closed by Return position (ADMIN-only) or cancelled by the investor. rejection_reason recorded; failure_type says which — see below.
FAILEDTransfer error. failure_type + error_message logged. Admin can retry.

REJECTED carries four different meanings, separated by failure_type (v3-99). The DB enum has no CANCELLED, so an investor's own cancellation collapses into the same status — reporting must split on this column or voluntary cancellations inflate the rejection rate.

failure_typeWritten byMeaning
UNFUNDEDReturn position (admin)Partner funding never arrived. No fault of the investor — the escrowed LP goes back
COMPLIANCEReturn position (admin)Holder no longer passes verification (revoked SBT / AML)
OTHERReturn position (admin)Anything else; the mandatory free-text note carries the detail
INVESTOR_CANCELLEDPOST /{id}/cancel (investor)The investor withdrew their own request. Never settable by an operator — that would misattribute the decision

failure_type is plain TEXT (no DB constraint), so the handler whitelist is the only thing keeping these buckets clean. Rows predating the split may carry the legacy value REJECTED; they are not retroactively reclassified, and on-chain-direct rejections mirrored by the indexer land with failure_type NULL (the event does not carry it).

Deprecated (v2.x → v3.0)

  • FM_ACCEPTEDFully removed (v3-34); no FM pre-acknowledge step. Partner coordination is a state (instant PENDING_RESERVE / epoch rollover), not a workflow step.
  • AS_POOL/FUND_POOL split flows — Unified.
  • Multi-Sig Escrow flow — Removed.

The FM_ACCEPTED enum value and POST /redemption-requests/{id}/fm-accept endpoint were dropped.

Note on RECOMMENDED / APPROVED / CANCELLED: these are flow / on-chain concepts (e.g. operator recommend → admin approve, investor cancel) that are not stored as their own DB enum values — the DB collapses the in-flight request into REQUESTED/PENDING_RESERVE/QUEUED and the terminal outcome into COMPLETED/REJECTED/FAILED.

Pool Status Flags pools table

Three flags control pool behavior at runtime.

FlagDescription
is_pausedSoft pause. New deposits blocked; redemptions process normally. Admin instant toggle.
is_emergency_frozenHard freeze. All activity blocked (deposits, redemptions, yield claims). Use sparingly — blocks investors from funds.
nav_per_tokenNAV value (default $1.00, capped at $1.00 max, no floor). Updated by oracle via updateNAV() with 24h timelock for decreases; increases apply immediately.

Two-level pause (v3.0)

v2.x had a single is_paused flag. v3.0 adds is_emergency_frozen for severe cases.

  • Soft pause (is_paused = true): No new deposits, but investors can still redeem. Use for temporary issues.
  • Emergency freeze (is_emergency_frozen = true): All activity halted. Use for compliance issues, security incidents, contract bugs.
Fund Status funds.status

ACTIVEINACTIVE

Status Definitions

StatusDescription
ACTIVEFund operational, pools visible and accepting investments.
INACTIVEFund suspended. Existing positions remain. Reversible by admin.
NAV Update Status nav_history.status

PENDINGAPPLIED

PENDINGCANCELLED (admin cancels during timelock)

Status Definitions

StatusDescription
PENDINGNAV decrease submitted. 24h timelock active. Admin can cancel during this window.
APPLIEDTimelock expired (decreases) or applied immediately (increases). New NAV active on pool.
CANCELLEDAdmin cancelled the pending update before timelock expiry.
Yield Distribution Status yield_distributions.status

Yield distribution flow.

PENDINGPROCESSINGDISTRIBUTED

PROCESSINGSETTLED_NO_HOLDERS

PROCESSINGFAILED

🟢 v3.0 flow

  1. Partner calls Pool.depositYield(gross) → status = PENDING
  2. Aset Lambda picks up event, calculates net (off-chain)
  3. Lambda calls Pool.settleYield(stablecoin, net, treasury_fee, pool_mgmt_fee) → status = PROCESSING
  4. Yield available for LP holders to claim, fees already paid → status = DISTRIBUTED

⚠️ Step 3 was two calls until v3-102; the fee call could fail after the distribution had already succeeded, and the row still finalized as DISTRIBUTED.

Status Definitions

StatusDescription
PENDINGForbidden on yield_distributions since migration 0113 (v3-104) — CHECK (status <> 'PENDING'), column default moved to PROCESSING. It only ever came from the removed legacy server-key create path, where a crash between the insert and settleYield left an orphan no reconciler could heal. ⚠️ The enum value survives for yield_distribution_investors.status, where it means "allocation not yet claimed".
PROCESSINGThe FM's depositYield landed on-chain (deposit_tx_hash recorded) and Aset's settleYield is outstanding. This — not PENDING — is the "recorded but not yet distributed" state. Older than 24h = stalled: the money is in the pool and holders have not been paid; POST /{id}/distribute is the recovery, and it is surfaced as dashboard_alert_counts.stalled_yield and the Yield screen's Stalled list.
DISTRIBUTEDYield available for LP holders to claim.
SETTLED_NO_HOLDERSThe settlement landed and credited nobody, because no LP was eligible — an empty pool, or one where every share is escrowed for redemption. Not a failure: the fee legs paid and the net yield stays in unclaimedYield, claimable by a later distribution once holders exist. Terminal for this row.
FAILEDTransaction error. failure_type + error_message logged.

🔴 The status is derived from what the chain did, never from what is missing (0185)

money_distribution_list.status is a CASE over the ledger, not a stored column. It used to read PROCESSING as the fallthrough — "no failure recorded and no event seen, so it must still be in flight" — and absence has two meanings.

settleYield credits nobody when no LP is eligible, while the fee legs still pay (drawn from the period, not from the credit; pinned by test_SettleYieldWithNoEligibleHoldersStillPaysFees). The transaction succeeded and money moved, yet no YieldDistributed was emitted — so the row sat in PROCESSING for ever, un-retryable behind the one-way distribution_started_at claim, and the 24h sweep told an operator to run a distribution that had already run.

The fix is on-chain: YieldDistributed is emitted on every settlement, reporting what was booked, so crediting nobody is (0, 0) rather than silence. The signature is unchanged, so topic0 is unchanged and nothing re-indexes. It had to be on-chain — a no-holder settlement with no fee legs emitted nothing at all, leaving no evidence the transaction happened for any view to recover.

Two off-chain patches were tried first and both were wrong: recording failure_type = 'NO_ELIGIBLE_HOLDERS' borrowed the failure channel for a settlement that succeeded, and refusing the call in the handler re-imposed off-chain the exact guard the contract test forbids.

A due-but-unrecorded period is not a row at all (v3-104). It exists only as pools.next_yield_due, and GET /yield-distributions?include_due=true synthesizes it into the read model with a server-computed estimate. Materialising a status='DUE' row was rejected: money-less rows in the ledger would have to be excluded by platform_stats total yield, the investor lists, the indexer's tx_hash reconcile and the /{id}/distribute guard, and one missed exclusion overstates what was paid to holders.

SBT (Soulbound Token) Status users.sbt_status

NOT_MINTEDMINTED

NOT_MINTEDFAILED (mint tx reverted or timed out)

FAILEDMINTED (retry succeeds)

MINTEDNOT_MINTED (login reconcile found no token on-chain — see below)

Status Definitions

StatusDescription
NOT_MINTEDKYC not approved, or mint queued but not yet confirmed on-chain.
MINTEDOn-chain confirmed, sbt_tx_hash stored.
FAILEDMint tx reverted or timed out (after queue retries). sbt_error logged. Re-queued by sweep / admin retry.

Async mint: the mint runs on the FIFO sbt-mint queue, so sbt_status lags kyc_status = APPROVED briefly. The admin KYC page shows a transient MINTING badge (frontend-only, not a DB value) while the row is queued. See KYC & Identity → SBT Minting & Recovery.

On-chain ≠ DB: users.sbt_status (this 3-value column) tracks our mint pipeline. The exit gate reads a separate on-chain four-state model (NONE / VALID / EXPIRED / REVOKED) via kycStateOf — see 03-kyc-identity. A revoked holder keeps sbt_status = MINTED (token still exists) but is REVOKED on-chain.

The MINTEDNOT_MINTED edge is a reconcile, not a pipeline step. POST /auth/verify compares the row against kycStateOf and only a definitive NONE (no token) clears it, alongside role = GUEST. EXPIRED and REVOKED do not clear it — they keep the token and the id — and a failed on-chain read writes nothing. The full table, and why reading the boolean isValidKYC here was a bug, is in KYC & Identity → Login-time reconcile.

Pool Lifecycle Status pools.lifecycle_status

DRAFTUPCOMINGACTIVECLOSED (offering over) → MATURED (FIXED_TERM)

DRAFTUPCOMINGACTIVEMATURED (FIXED_TERM, no separate offering close)

ACTIVEIMPAIRED (partner distress) → WIND_DOWN (terminal) — v3-12

ACTIVE or UPCOMINGCLOSED → back to ACTIVE (admin POST /pools/{id}/close, reversible)

🔴 CLOSED is a stop on the way to MATURED now, not a sibling of it (0204 / v3-141). A pool with a subscription_end_date closes its offering on that date and matures at its term, so it passes through both in that order. Before 0204 the two dates were the same column, the maturity pass ran first, and the auto-close pass therefore never fired at all. The maturity pass now selects ACTIVE and CLOSED, because a pool that closes early and cannot then mature reads as never-matured everywhere (notifications, badges, the partial-vs-full rule, the yield-due cap) even though it still redeems penalty-free on-chain — but only a CLOSED pool holding a subscription_end_date, so a hand-closed pool keeps its reopen route instead of being swept into a state nothing can return from. ✅ The chain agrees, in two rounds rather than one. LifecyclePolicy treated CLOSED as terminal, so the on-chain call reverted and the sweeper skipped the row rather than writing a status the contract does not share (v3-92). b619d9a added CLOSED → MATURED and removed MATURED → CLOSED (deployed 2026-08-20, impl 0xCfe751fd…); 3edf75b added CLOSED → ACTIVE (deployed 2026-08-21, impl 0x405E89Ec…, v3-142) — the reopen decision came after the first round was already built, so it missed that deploy by a day. ⚠️ Clones gives this TWO cutoffs, not one. A pool carries whichever rules its implementation had when it was created, and poolImplementation is immutable: a pool from before 08-20 cannot reach MATURED from CLOSED, and a pool from before 08-21 cannot reopen. Both are harmless on the maturity leg, since a pool created before 0204 has no subscription_end_date and never reaches CLOSED by sweep — but the reopen leg is reachable by hand on any pool, which is why it is called out again in the transition table below. Audited 2026-08-21: none of dev's nine deployed pools is on the current implementation (apps/contract/sepolia.md → Live pool generations).

⚠️ Earlier drafts wrote this as any state → CLOSED. It is not: closing a MATURED, IMPAIRED or WIND_DOWN pool would move it backwards out of a state it reached for a reason, and the handler refuses it (409). Only a pool still taking subscriptions has a subscription to close.

🔴 ARCHIVED is not a lifecycle status

It is not a value of pools.lifecycle_status and there is no transition to it. The DB enum has no ARCHIVED member. It is a display label derived from deleted_at in the admin mapper (admin-web/app/shared/api/pools.tsmapPoolRowToView), which returns 'ARCHIVED' whenever deleted_at is set, shadowing whatever the real lifecycle is.

So an archived pool is still CLOSED or MATURED or DRAFT underneath — archive is a third axis layered over lifecycle and flags, not a stage within it. Everything that reads lifecycle_status (the on-chain guards, the deposit gate, the redemption path) is unaffected by archiving. Treat it in the same family as is_paused: a flag whose label happens to win the badge. See Two axes → the hidden axis and v3-110.

Status Definitions

StatusDescription
DRAFTSaved, not deployed. Editable + deletable.
UPCOMINGDeployed, visible to investors, before start_date.
ACTIVEOpen for investment. start_date reached. NAV changes don't auto-block.
IMPAIREDPartner distress detected (DPD spike, delayed NAV updates, late yield). Deposits paused; redemptions remain open with lockup/penalty waived. Recoverable — admin can return to ACTIVE if partner stabilizes.
CLOSEDFund-raising over. No new deposits; redemptions and yield claims continue normally, and so does the distribution schedule (next_yield_due is no longer cleared on the way in: a closed pool still owes every remaining coupon). Reinvestment does not continue, since reinvest is whenActive on-chain. Reached by a manual admin close (POST /pools/{id}/close) or automatically at subscription_end_date (0204 / v3-141; it was end_date, which is maturity, and that pass never fired), and reversible back to ACTIVE (reopen) while the pool is otherwise healthy, because closing a subscription early is an operational call rather than a terminal one. ✅ Both routes shipped 2026-08-04; until then the state was read all over the product and written by nothing. 🔴 "Reversible" was written here before it was true on-chain. The endpoint and its button existed from 2026-08-04, but LifecyclePolicy had never allowed CLOSED → ACTIVE, so every reopen on a deployed pool answered 502 — this row asserted the off-chain half and did not check the chain. True since 2026-08-21 (v3-142), and only for pools created from that implementation onward.
MATUREDmaturity_days elapsed (for FIXED_TERM pools). All redemptions free of penalty.
WIND_DOWNTerminal state (partner unresponsive 60+ days OR escalation from IMPAIRED). nav_per_token set to distributable / (totalSupply − settledUnclaimedLp), where distributable = reserve plus recalled funds (epoch top-ups) and is not reduced by settled-but-unclaimed debt — that debt leaves the denominator instead (v3-100 numerator, R10 denominator; see 06 → R10); the pro-rata share is claimed via requestRedemption → claim (no dedicated redeem() function on-chain). No claimWindDown (removed in v3-12).

ARCHIVED is deliberately absent from this table — it is not a lifecycle value. See the callout above and the hidden axis.

Auto-Transitions (Scheduler)

TransitionTrigger
DRAFTUPCOMING🔴 Admin clicks "Publish" → published_at set. If start_date has already arrived the publish promotes straight to ACTIVE in the same request (pools.post.create / pools.post.lifecycle) — the admin wizards state which outcome applies before you confirm
UPCOMINGACTIVE🟢 pools.scheduler.lifecycle (hourly) — for pools published ahead of their start date, auto-transitions once start_date is reached
ACTIVE or CLOSEDMATURED🟢 When maturity_days elapsed (for FIXED_TERM), auto-transitions. CLOSED is in the filter since 0204 (v3-141): a pool whose offering closed before its term arrives at maturity already CLOSED, and every screen, notification and penalty-free-redemption path keys on MATURED rather than on the calendar. 🔴 Only a CLOSED pool with a subscription_end_date — a hand-closed pool is excluded so its reversible reopen is not taken away. ✅ On-chain since b619d9a (deployed 2026-08-20); MATURED → CLOSED was removed in the same change, so MATURED is the setter's end state. ⚠️ Clones cutoff: pools created before that implementation keep CLOSED terminal. They cannot arrive there by sweep either (no subscription_end_date before 0204), so the cutoff bites only a pool closed by hand
ACTIVEIMPAIRED🔴 Admin proposeImpairment() → 7-day timelock → executeImpairment() (v3-12). This is the only routesetLifecycleStatus refuses IMPAIRED as a target, and a tranche write-down must use it too (v3-106)
IMPAIREDACTIVE🔴 Admin cancelImpairment() (partner recovered, distress resolved)
ACTIVE or IMPAIREDWIND_DOWN🔴 Admin proposeWindDown() after 60-day silence → 30-day timelock → executeWindDown(). Note: only the 30-day timelock (WIND_DOWN_TIMELOCK) is enforced on-chain; the 60-day partner-silence period is an off-chain precondition with no automated tracker — proposeWindDown() is a manual admin action.
ACTIVECLOSED✅ Admin POST /pools/{id}/close or pools.scheduler.lifecycle once subscription_end_date passes (0204 / v3-141; v3-110 B read end_date, which is maturity). Blocks deposits only; redemptions and claims continue. On-chain setLifecycleStatus first, DB after. ⚠️ The scheduler pass still runs after the maturity pass, so a pool past both dates leaves as MATURED and is never demoted — demoting would reinstate early-exit penalties on holders who had earned their way out. A pool with no subscription_end_date is not swept here at all
CLOSEDACTIVE✅ Admin reopen (same endpoint, action: reopen). Refused while the pool is emergency-frozen, since a frozen pool cannot take the deposits the reopen advertises. Does not re-set is_paused (v3-78: de-escalation never auto-restores). Clears subscription_end_date only when it is in the past — a future one is kept, so the pool closes again on that day, and the reopen dialog names the date rather than leaving it to be found on another tab. 🔴 This row read ✅ for two and a half weeks while every reopen answered 502. The endpoint shipped 2026-08-04 and LifecyclePolicy had never allowed the transition; on-chain only since 3edf75b / impl 0x405E89Ec…, 2026-08-21 (v3-142). ⚠️ Clones cutoff: an older pool can never reopen, and nothing off-chain records which implementation a pool came from, so no screen can tell the two apart

Archiving is not in this table. It sets deleted_at and leaves lifecycle_status untouched.

🔴 Writing IMPAIRED to the database is not a transition (v3-106)

pools.lifecycle_status = 'IMPAIRED' with no on-chain executeImpairment behind it is the v3-92 defect class: the chain stays ACTIVE, so the lockup / penalty waiver and the deposit block that IMPAIRED grants never take effect — the UI promises distress terms the contract does not honour. It is also self-locking: pools.post.impairment propose 409s (proposal timestamp set, status no longer ACTIVE), execute clears its DB-side timelock check and then reverts on-chain with NoImpairmentProposal → 502, and setLifecycleStatus refuses the target. No product path reconciles it.

tranche.post.writedown did exactly this for a wiped Junior until v3-106 (fixed 2026-08-03): it now calls proposeImpairmentOnChain, stores proposed only, and lets executeImpairment flip the label. If deposits must stop before the timelock elapses, that is is_paused + the on-chain pause() (see the auto-clear table below), never a lifecycle write.

Deprecated

  • DISTRESSED status — Replaced by ACTIVE + writedown (NAV < 1.0) + is_paused combination.
  • claimWindDown() function — Removed in v3-12. Use requestRedemption → claim after WIND_DOWN sets NAV to distributable / (totalSupply − settledUnclaimedLp) (v3-100 + R10); there is no dedicated redeem() function on-chain.
Combined Pool State — Capability Matrix lifecycle × flags

The sections above define each status field independently. This section combines them into the one question that actually drives the UI and the on-chain guards: given a pool's combined state, what can an investor do?

Two axes, not one long list

A pool's investor-visible state is not a flat enum. It is:

  • Lifecycle (6): UPCOMING / ACTIVE / CLOSED / MATURED / IMPAIRED / WIND_DOWN — the pool's stage and health (pools.lifecycle_status).
  • Flags (independent, layered on top): is_paused (deposits off), is_emergency_frozen (everything off).
  • Visibility (a third axis, v3-110): deleted_at (archived — terminal-ish, gated) and is_hidden (hidden while operating — freely reversible). Neither is a lifecycle value; both only decide whether investors can see the pool.
  • Modifiers (axes, not states): writedown (nav_per_token < 1.0), fully-subscribed (tvl >= capacity), proposed timelocks (impairment / NAV / wind-down proposed — still ACTIVE), epoch settlement (redemption mechanism).

So "Active + writedown", "Impairment proposed", and "Fully subscribed" are ACTIVE × modifier, not separate lifecycle states. An investor sees one combined state at a time.

The hidden axis — deleted_at vs is_hidden

Two different needs were being served by one lever, which is why "archive" drifted into meaning both "retire this finished pool" and "take this down for a moment." v3-110 (decision A) splits them:

deleted_at (Archive)is_hidden (Hide)
IntentRetire a finished pool from the operator's working setTemporarily take a pool out of the investor surface while it keeps operating
Allowed whenEvery investor position is zero, in any lifecycle but DRAFT (which is routed to hard delete instead)Any state, any time
ReversibleYes, via restore — but a reason is required and the act is auditedYes, freely — an ordinary toggle
On-chain effectMirrors an on-chain pause() so an archived pool cannot take a deposit through a direct contract callNone
LifecycleUntouchedUntouched
BadgeShadows the lifecycle chip as ArchivedShown to operators as a Hidden marker, not as the pool's status

Both halves shipped 2026-08-04. The archive guard used to check open positions only when lifecycle_status = 'ACTIVE', so a CLOSED / MATURED / IMPAIRED / WIND_DOWN pool holding investor positions could be archived — hiding a pool people still had money in, in exactly the states a pool passes through on its way out. The check now runs for every lifecycle but DRAFT, and the decision lives in a pure lib/shared/business/pool-archive.ts with the enum enumerated in tests, so a lifecycle value added later fails a test instead of silently skipping the guard.

Archiving also pauses the contract now. It used to write deleted_at and nothing else, which left the pool open to deposit() for anyone holding the address while it was invisible in the console. Restore deliberately does not unpause (v3-78): the pool comes back paused and reopening it is a separate act.

is_hidden exists (0123) but is admin-list only — narrower than the decision text, which said investor list and PDP. A holder must be able to reach the pool to redeem, claim yield and read a write-down notice.

Capability matrix

StateDepositWithdraw / RedeemClaim yieldReversible?Meaning
ACTIVENormal
ACTIVE + writedownnav_per_token < 1.0, assets marked down but pool functioning
Paused (is_paused)❌ (also blocks reinvest — ⚠️ reinvest is not offered on any screen in MVP, 2026-08-27, not yet reflected, v3-151; the on-chain function stays)Instant admin toggleCapital in paused for a routine / operational reason. Manual only — nothing auto-pauses a pool (v3-94)
IMPAIRED✅ (lockup & penalty waived)Recoverable (→ ACTIVE via cancelImpairment)Asset distress under review; principal may be written down
WIND_DOWN⚠️ pro-rata share of what the pool holds (nav_per_token = distributable / (totalSupply − settledUnclaimedLp), v3-100 + R10) via requestRedemption → claimTerminal / irreversibleLiquidation
Frozen (is_emergency_frozen)❌ (whole freeze)❌ first 72h, then ✅❌ first 72h, then ✅Time-bounded emergencyEverything halted, asymmetrically — see below
MATURED✅ penalty-freeTerminalTerm complete
CLOSED✅ (existing holders)No longer raising
UPCOMING— (no positions yet)ACTIVENot open yet
Fully subscribed❌ (at capacity)ACTIVE but tvl >= capacity
Archived (deleted_at)❌ (on-chain pause() mirrored)Restore (reason required, audited)Pool retired from view. Only reachable when every position is already zero, so there is nobody left to redeem or claim — which is precisely why hiding it is safe (v3-110 A)
Hidden (is_hidden)Free toggleRemoved from the investor list and PDP, but fully operational for anyone holding a position. Changes visibility only — no capability changes at all

Archived vs Hidden — what an investor experiences

The two rows above look similar and are not. Archive is gated on there being no investor left to affect: positions must be zero, so the ❌ / — cells describe a pool nobody holds. Hide affects visibility and nothing else: an existing holder keeps depositing, redeeming and claiming exactly as before; they simply cannot find the pool by browsing. Notifications for a hidden pool continue, because the holder's position is live and silence would be the misleading signal.

An archived pool's holders, by construction, do not exist — so "do archived investors still get notified?" has no subject. If a pool with live positions needs to come off the surface, that is Hide, and the guard exists to force that choice rather than let archive quietly do both.

Deposit gating (FE + on-chain, must agree)

Deposit is allowed only when all of: lifecycle_status = 'ACTIVE' AND is_paused = false AND is_emergency_frozen = false AND tvl + amount <= capacity. (On-chain: deposit() carries whenActive · whenNotPaused · whenNotFrozen · duringSubscription modifiers — see 08a contract reference.)

  • Blocked: Upcoming · Impaired · Wind-down · Frozen · Paused · Matured · Closed · Fully-subscribed
  • Allowed (still ACTIVE): Active · Active + writedown · Impairment proposed · NAV-change proposed · Wind-down proposed · Epoch settlement

Priority when several apply

is_emergency_frozen > WIND_DOWN > IMPAIRED > is_paused > writedown. (A writedown banner shows alone only when none of the above apply.)

Auto-clear (flag exclusivity, v3-78)

Because the flags layer on top of lifecycle, admin actions auto-clear lower flags in the same write, so only one effective status is ever live. This is done atomically in the BE handler — the admin calls one endpoint, not two:

ActionSetsAuto-clears
Pauseis_paused = true— (lowest)
Impairment (execute)lifecycle = IMPAIREDis_paused (IMPAIRED already blocks deposits) + on-chain unpause()
Freezeis_emergency_frozen = trueis_paused + on-chain unpause()
Wind-down (execute)lifecycle = WIND_DOWNis_paused + on-chain unpause() — and does not set is_emergency_frozen (see below)
Close (POST /pools/{id}/close)lifecycle = CLOSEDis_paused + on-chain unpause() (v3-110 D)
Maturity / auto-close (pools.scheduler.lifecycle, on end_date and subscription_end_date respectively)lifecycle = MATURED or CLOSEDis_paused + on-chain unpause() (v3-110 D)

The last two rows are v3-110's addition, and they extend the rule from escalation to lifecycle progression. "Temporarily paused, coming back" stops being true the moment a pool matures or closes for good, and leaving both set produced MATURED + PAUSED — a combination the admin list had no single chip for. ⚠️ The scheduler runs these unattended, which makes the v3-92 rule below load-bearing rather than advisory: an unattended DB-only clear would strand pools nobody is watching.

Reversing a higher state does not auto-restore a lower flag (unfreeze leaves is_paused = false; re-pause manually). pools.post.pause also rejects a pause unless the pool is ACTIVE and not frozen.

Clearing is_paused must also clear the on-chain pause (v3-92)

is_paused is on-chain SoT (08 §A — a DB-only pause is a security gap), and OpenZeppelin Pausable is an independent contract flag: neither emergencyFreeze() nor a lifecycle change touches it (PlatformPool.emergencyFreeze only sets isEmergencyFrozen). So a DB-only auto-clear desyncs — the pool stays paused() on-chain while the DB reads "not paused":

  • deposit() keeps reverting whenNotPaused invisibly (admin + investor UI show the pool as open),
  • a later pause() reverts EnforcedPause() → the endpoint 502s with no recovery path in the UI (the toggle offers pause, not unpause, because the DB says is_paused = false),
  • nothing self-heals it: the on-chain indexer does not mirror paused() back into the DB.

The escalating handlers (pools.post.freeze · .impairment execute · .wind-down execute) — and since v3-110 also pools.post.close and the lifecycle scheduler — therefore call the shared clearOnChainPause(poolAddress, chainId) helper, which reads the live paused() and sends unpause() when set. Rules:

  1. Order: escalation tx first, then unpause(). Unpausing first would leave the contract accepting deposits if the escalation then failed — exactly the 08 §A gap. unpause() carries no whenNotFrozen / whenActive guard, so it works after the escalation has landed (only pause() is guarded).
  2. The DB mirrors the chain, not the intent. is_paused = false is written only if the contract is actually unpaused. If the unpause tx fails the column stays true — both flags block deposits and the higher state wins the display priority, so a truthful double-flag beats a lying single one. The failure is audited as pause_clear_failed: true; a successful lift records unpause_tx_hash.
  3. Reads the chain, not the DB mirror, so an already-desynced pool repairs itself on the next escalation.

Wind-down does not freeze (v3-78, Option A). executeWindDown previously also set is_emergency_frozen = true — but since is_emergency_frozen outranks WIND_DOWN in priority, the UI read "Frozen (all halted)" and hid the pro-rata redemption path, contradicting wind-down's whole purpose. On-chain, requestRedemption has no whenNotFrozen guard and RedemptionLib permits redemption during wind-down, so redemption always worked — only the DB flag + FE display were wrong. Wind-down now leaves is_emergency_frozen = false; deposits are already blocked by the WIND_DOWN lifecycle (whenActive fails), so the freeze was redundant.

Why the distress states are not merged

Paused / Impaired / Wind-down all block deposits, but differ on withdrawal mechanics, reversibility, and meaning — so the answer to "can I get my money out, and at what value?" differs, and merging them would mislead:

  • Paused — routine, instantly reversible, withdrawals fully normal.
  • IMPAIRED — recoverable distress, withdrawals allowed with lockup / penalty waived, principal may be written down.
  • WIND_DOWN — terminal liquidation, no normal redemption — only pro-rata payout from the pool's distributable liquidity at reduced NAV.
  • Frozen — emergency, withdrawals also blocked, time-bounded.

Frozen is asymmetric and self-expiring (v3-28)

The matrix row above is not a flat "everything off". A freeze has two clocks, and both matter to what the UI may claim:

Blocked forMechanism
Deposits (capital in)the whole freezedeposit() whenNotFrozen
Withdrawals + yield claims (value out)the first 72h only (FREEZE_EXIT_WINDOW from freeze_started_at)PoolCommonLib.checkExitNotBlocked
The freeze itselfexpires after 7 days (FREEZE_MAX_DURATION) with no transaction_frozenActive simply reads falseso a pool can never be bricked

unfreeze (PAUSER) lifts it early; extending past 7 days needs a governance timelock. Consequences for copy: a frozen banner must never read as open-ended ("please check back later"), because the halt always has a stated end. freeze_exit_window_ends_at and freeze_auto_expires_at are exposed on the pool read model for exactly this.

Impairment (mark-down under review) and wind-down (liquidation) are also contractually distinct (SSA loss-absorption / NPL clauses), so they cannot be collapsed into one state.

Status Banner Copy — SoT v3-95

The capability matrix above says what a state does. This section pins what we are allowed to say about it. It exists because status copy drifted from behaviour in both apps and shipped claims the code does not make — an admin banner that announced an auto-pause feature that does not exist, and an investor drawer that invented a pause date, a review deadline, a weekly update cadence and a principal guarantee (v3-95).

Copy is not decoration here: it is the investor's only account of whether they can get their money out. Treat a wrong status line as a defect of the same class as a wrong nav_per_token.

Where the copy lives, and what enforces it (v3-96)

The rules below are not honour-system. Three mechanisms back them, in ascending order of how hard they are to ignore:

MechanismWhereCatches
Copy modules — status copy is not written inline in componentsapps/web/app/shared/copy/status.ts, apps/admin-web/app/shared/copy/status.ts, apps/infra/lib/shared/notifications/copy.tsMakes the whole surface auditable in one file, and a copy change reads as a copy diff instead of hiding in JSX. Each string carries the code path that enforces its claim.
copy-guard — build-gating scriptscripts/check-copy.mjs, wired into all three apps' build (--app web / admin-web / infra)Affirmative guarantee language, invented deadlines and weekday cadences, and any clock read (Date.now(), new Date()) inside a copy module. Negated disclaimers ("returns are not guaranteed") pass by design.
eslint no-restricted-syntaxboth frontends' eslint.config.js, scoped to app/shared/copy/**The same mistakes, while typing.

Backend copy is in scope too. apps/infra was outside the guard until 2026-07-30, even though the notification registry is the biggest user-facing copy surface in the product — every email and in-app card renders from it, and an email is already delivered by the time anyone reviews it. The copy-module clock rule keys on the exact basename copy.ts, so siblings that legitimately read a clock (notify.ts windowing a dedup lookup, format-date.ts) are not swept in.

The guard is a build step rather than a lint rule on purpose: CI runs build, not lint (deploy-web.yml / deploy-admin-web.yml run pnpm --filter … build:dev), and pnpm lint currently fails in both frontends on a pre-existing error backlog — so an eslint-only gate would gate nothing. False positive? Add copy-guard-allow: <reason> in a comment on or above the line; the reason is mandatory, because the goal is that a bypass is recorded, not that it is difficult.

⚠️ Coverage gap on infra: there is no CI workflow for apps/infra (only deploy-web / deploy-admin-web / deploy-docs), so the infra gate runs from its own build and test scripts — locally and pre-deploy, not on push. Note also that cdk deploy bundles handlers directly and does not run build, so a deploy that skips pnpm build / pnpm test skips the gate. Closing this properly needs an infra CI job.

If you cannot verify what a state does, do not write plausible text. Leave TODO(copy) and ask — this is CLAUDE.md rule #2 (the text half, 2-b): the same principle that says an unwired button must be disabled rather than fake.

Five rules

  1. Never describe a state as automatic unless a scheduler actually writes it. Auto-transitions are enumerated in Auto-Transitions (Scheduler) above; anything absent from that list is operator-driven. Specifically: nothing auto-pauses a pool (v3-94).
  2. No protection, guarantee or recovery language. Not "your investment is protected", not "principal secured", not "principal protection". Wind-down is explicitly pro-rata and may fall well short of principal; collateral may be UNSECURED. This is also the one class of copy error with legal exposure.
  3. No invented dates or cadences. Every date shown must come from a column (freeze_exit_window_ends_at, next_yield_due, end_date, …). A date derived from Date.now() at render time is a fabrication, not a default. Same for commitments we do not operate ("updates every Friday").
  4. Name the axis, not just the verb. State whether capital in (deposit + reinvest) or value out (redeem + withdraw + claim) is affected. "Paused" alone reads as "my money is stuck", which is the opposite of what is_paused does.
  5. Every affordance in a banner or drawer must work. No dead buttons (CLAUDE.md rule #2). If there is no document to open, there is no "Read notice" button.

Investor copy (apps/webshared/ui/PoolStatusAlert.tsx)

VariantTitleCopyActually blocks
pausedTemporarily PausedNew deposits and reinvestment are paused. Your existing position, redemptions, withdrawals and yield claims are unaffected.capital in only
writedownPrincipal WritedownPrincipal value has dropped (NAV $x). Deposits and withdrawals are operating normally.nothing
impairedUnder ReviewNew investments are temporarily paused. Withdrawals remain available. We're monitoring the situation.deposits (lockup + penalty waived on exit)
winddownWinding Down…pro-rata within available reserves. Recovery may fall well short of your principal…deposits; exit is pro-rata only
frozenTemporarily UnavailableDeposits, withdrawals and yield claims are temporarily halted. Withdrawals and yield claims reopen {freeze_exit_window_ends_at}; the halt lifts automatically by {freeze_auto_expires_at}.all — asymmetric, see v3-28 above
maturedPool MaturedMatured on {date}. Redeem penalty-free. Redemptions are investor-initiated.deposits
closedPool ClosedNo longer accepting investments. Existing investors can claim yield and request redemptions.deposits
fullFully SubscribedNot accepting new investments. Your existing position is not affected.deposits (at capacity)
upcomingComing SoonInvestment opens {start_date}deposits (not open yet)

The paused variant is the only one with a detail drawer. It states what is paused, what continues, and that a pause has no fixed duration and is not on its own a statement about the pool's assets — nothing more.

Admin copy (apps/admin-web)

SurfaceCopy
Pool detail top banner (is_paused)Deposits are paused on this pool. Redemptions, withdrawals and yield claims continue. (+ "Yield distribution is also overdue — distribute before resuming." when yield_overdue)
Controls → Deposit PauseBlocks capital in — new deposits and yield reinvestment. Redemptions, withdrawals and yield claims continue. Reversible anytime; nothing auto-pauses a pool.
Controls → Emergency FreezeFull halt of deposits, withdrawals and yield claims. Withdrawals auto-resume in 72h; auto-expires in 7 days.
Controls → ImpairmentPublic distress signal. Deposits pause; withdrawals stay open with penalties waived. 7-day timelock · reversible.
Controls → Wind-downIrreversible. Permanently closes the pool — investors redeem pro-rata from what remains. 30-day timelock before execute.

The pause banner and the pause control must not be labelled "Resume Pool" — a pause never stopped the pool, only capital in. Both say Resume Deposits.

Resolved (2026-08-04) — wind-down "reserve" wording

Both apps used to describe the wind-down payout as pro-rata from reserve, which matched the contract when it was written (executeWindDown: navPerToken = reserveBalance / totalSupply), on the reasoning that reserve is fed only by the reserveBps split on deposit / reinvest with no path for a partner to pay recovered capital back in — so under the reserve-zero launch assumption the formula yields NAV 0 and the copy truthfully described a zero payout. v3-100 ended that premise: the numerator became reserveBalance + totalEpochTopUp, and totalEpochTopUp comes from the partner's fundRedemption, so recalled capital does reach it. (v3-100 also subtracted redemptionCommitted; that was removed as a double-count in 0abe168, which likewise fixed the denominator to totalSupply − settledUnclaimedLpR10, deployed 2026-08-04, new pools only.)

Decision (2026-08-04): switch the wording. Every surface now says available liquidity, which is that numerator — investor pool status (web/shared/copy/status.ts), the admin wind-down controls (already worded this way), and the pools.post.wind-down feed entries. The reserve-only phrasing survived nowhere in product copy; what remained were developer docblocks in both status.ts files naming the superseded formula, and those now name the current one.

What the switch does not claim. It is a change of basis, not of expectation: in a wind-down the partner is unresponsive by definition, so a reserve-zero pool still prices at or near zero unless they actually funded something. The investor string keeps its caveat sentence ("Recovery may fall well short of your principal") for exactly this reason, and no surface quotes the formula itself.