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.
PENDING → PROCESSING → COMPLETED
PROCESSING → FAILED (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
| Status | Description |
|---|---|
PENDING | Deposit transaction submitted, awaiting confirmation. |
PROCESSING | Block confirmation in progress (rare; usually atomic with PENDING). |
COMPLETED | LP minted, reserve retained, fund_wallet received its share. |
FAILED | Transfer or mint error. failure_type + error_message logged. |
Deprecated (v2.x → v3.0)
REFUNDEDstatus (D+7 mechanism) — Removed. No refund flow in v3.0. Enum value dropped fromdeposit_statusin 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_STARTED → IN_REVIEW → APPROVED
IN_REVIEW → REJECTED
Status Definitions
| Status | Description |
|---|---|
NOT_STARTED | No verification on record. Includes a holder who opened the SDK and submitted nothing — that only records kyc_level. |
IN_REVIEW | The applicant was submitted and is awaiting a verdict. |
APPROVED | KYC passed, SBT minted on-chain. |
REJECTED | Failed verification. See reject_type (RETRY / FINAL). |
IN_REVIEWhas one writer:kyc/enter-review.ts→markApplicantInReview, fed by SumSub'sapplicantPending/applicantOnHoldwebhook or byPOST /kyc/syncreading the applicant'sreviewStatusback. 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).
REQUESTED → PROCESSING → COMPLETED
REQUESTED → PENDING_RESERVE → PROCESSING → COMPLETED (when partner sends funds)
REQUESTED / PENDING_RESERVE → REJECTED · PROCESSING → FAILED
🟢 Instant flow
- Investor calls
requestRedemption→ NAV snapshot + penalty computed, and the same transaction checks the reserve against the gross payout. - 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. - Short → LP escrowed, status
PENDING_RESERVE,RedemptionPendingReserveemitted with the shortfall. The partner'sfundRedemptionauto-settles the request the moment the balance covers it. - Two manual paths remain, neither of them a gate:
approveRedemptionis an optional settle for aPENDING_RESERVErequest the reserve later grew into (it reverts on aREQUESTEDone), andclaimRedemptionFallbackis 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.
REQUESTED → QUEUED → COMPLETED (fully filled + claimed)
QUEUED → PARTIALLY_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
- Investor requests → LP locked, enrolled into
currentEpochId, status =QUEUED(no NAV snapshot) - At deadline,
executeEpoch()computesfillRatio+ settlement NAV (O(1), no money movement) — 100% automatic unless an anomaly is held - Investor (or keeper, claim-on-behalf) calls
claimRedemption()→ filled portion paid, remainder →PARTIALLY_FILLEDrollover - Once remainder == 0 →
COMPLETED
Status Definitions
| Status | Description |
|---|---|
REQUESTED | Investor 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. |
PROCESSING | Funds being sent. |
COMPLETED | USDC received by investor. payout_tx_hash recorded. |
REJECTED | Request closed by Return position (ADMIN-only) or cancelled by the investor. rejection_reason recorded; failure_type says which — see below. |
FAILED | Transfer 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_type | Written by | Meaning |
|---|---|---|
UNFUNDED | Return position (admin) | Partner funding never arrived. No fault of the investor — the escrowed LP goes back |
COMPLIANCE | Return position (admin) | Holder no longer passes verification (revoked SBT / AML) |
OTHER | Return position (admin) | Anything else; the mandatory free-text note carries the detail |
INVESTOR_CANCELLED | POST /{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_ACCEPTED— Fully removed (v3-34); no FM pre-acknowledge step. Partner coordination is a state (instantPENDING_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 intoREQUESTED/PENDING_RESERVE/QUEUEDand the terminal outcome intoCOMPLETED/REJECTED/FAILED.
Pool Status Flags pools table
Three flags control pool behavior at runtime.
| Flag | Description |
|---|---|
is_paused | Soft pause. New deposits blocked; redemptions process normally. Admin instant toggle. |
is_emergency_frozen | Hard freeze. All activity blocked (deposits, redemptions, yield claims). Use sparingly — blocks investors from funds. |
nav_per_token | NAV 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
ACTIVE ↔ INACTIVE
Status Definitions
| Status | Description |
|---|---|
ACTIVE | Fund operational, pools visible and accepting investments. |
INACTIVE | Fund suspended. Existing positions remain. Reversible by admin. |
NAV Update Status nav_history.status
PENDING → APPLIED
PENDING → CANCELLED (admin cancels during timelock)
Status Definitions
| Status | Description |
|---|---|
PENDING | NAV decrease submitted. 24h timelock active. Admin can cancel during this window. |
APPLIED | Timelock expired (decreases) or applied immediately (increases). New NAV active on pool. |
CANCELLED | Admin cancelled the pending update before timelock expiry. |
Yield Distribution Status yield_distributions.status
Yield distribution flow.
PENDING → PROCESSING → DISTRIBUTED
PROCESSING → SETTLED_NO_HOLDERS
PROCESSING → FAILED
🟢 v3.0 flow
- Partner calls
Pool.depositYield(gross)→ status =PENDING - Aset Lambda picks up event, calculates net (off-chain)
- Lambda calls
Pool.settleYield(stablecoin, net, treasury_fee, pool_mgmt_fee)→ status =PROCESSING - 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
| Status | Description |
|---|---|
PENDING | Forbidden 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". |
PROCESSING | The 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. |
DISTRIBUTED | Yield available for LP holders to claim. |
SETTLED_NO_HOLDERS | The 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. |
FAILED | Transaction 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, andGET /yield-distributions?include_due=truesynthesizes it into the read model with a server-computed estimate. Materialising astatus='DUE'row was rejected: money-less rows in the ledger would have to be excluded byplatform_statstotal yield, the investor lists, the indexer'stx_hashreconcile and the/{id}/distributeguard, and one missed exclusion overstates what was paid to holders.
SBT (Soulbound Token) Status users.sbt_status
NOT_MINTED → MINTED
NOT_MINTED → FAILED (mint tx reverted or timed out)
FAILED → MINTED (retry succeeds)
MINTED → NOT_MINTED (login reconcile found no token on-chain — see below)
Status Definitions
| Status | Description |
|---|---|
NOT_MINTED | KYC not approved, or mint queued but not yet confirmed on-chain. |
MINTED | On-chain confirmed, sbt_tx_hash stored. |
FAILED | Mint 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-mintqueue, sosbt_statuslagskyc_status = APPROVEDbriefly. 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) viakycStateOf— see 03-kyc-identity. A revoked holder keepssbt_status = MINTED(token still exists) but isREVOKEDon-chain.
The
MINTED→NOT_MINTEDedge is a reconcile, not a pipeline step.POST /auth/verifycompares the row againstkycStateOfand only a definitiveNONE(no token) clears it, alongsiderole = GUEST.EXPIREDandREVOKEDdo 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 booleanisValidKYChere was a bug, is in KYC & Identity → Login-time reconcile.
Pool Lifecycle Status pools.lifecycle_status
DRAFT → UPCOMING → ACTIVE → CLOSED (offering over) → MATURED (FIXED_TERM)
DRAFT → UPCOMING → ACTIVE → MATURED (FIXED_TERM, no separate offering close)
ACTIVE → IMPAIRED (partner distress) → WIND_DOWN (terminal) — v3-12
ACTIVE or UPCOMING → CLOSED → 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.ts → mapPoolRowToView), 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
| Status | Description |
|---|---|
DRAFT | Saved, not deployed. Editable + deletable. |
UPCOMING | Deployed, visible to investors, before start_date. |
ACTIVE | Open for investment. start_date reached. NAV changes don't auto-block. |
IMPAIRED | Partner 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. |
CLOSED | Fund-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. |
MATURED | maturity_days elapsed (for FIXED_TERM pools). All redemptions free of penalty. |
WIND_DOWN | Terminal 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). |
ARCHIVEDis deliberately absent from this table — it is not a lifecycle value. See the callout above and the hidden axis.
Auto-Transitions (Scheduler)
| Transition | Trigger |
|---|---|
DRAFT → UPCOMING | 🔴 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 |
UPCOMING → ACTIVE | 🟢 pools.scheduler.lifecycle (hourly) — for pools published ahead of their start date, auto-transitions once start_date is reached |
ACTIVE or CLOSED → MATURED | 🟢 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 |
ACTIVE → IMPAIRED | 🔴 Admin proposeImpairment() → 7-day timelock → executeImpairment() (v3-12). This is the only route — setLifecycleStatus refuses IMPAIRED as a target, and a tranche write-down must use it too (v3-106) |
IMPAIRED → ACTIVE | 🔴 Admin cancelImpairment() (partner recovered, distress resolved) |
ACTIVE or IMPAIRED → WIND_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. |
ACTIVE → CLOSED | ✅ 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 |
CLOSED → ACTIVE | ✅ 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_atand leaveslifecycle_statusuntouched.
🔴 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_pausedcombination. claimWindDown()function — Removed in v3-12. UserequestRedemption→ claim after WIND_DOWN sets NAV todistributable / (totalSupply − settledUnclaimedLp)(v3-100 + R10); there is no dedicatedredeem()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) andis_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 — stillACTIVE), 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) | |
|---|---|---|
| Intent | Retire a finished pool from the operator's working set | Temporarily take a pool out of the investor surface while it keeps operating |
| Allowed when | Every investor position is zero, in any lifecycle but DRAFT (which is routed to hard delete instead) | Any state, any time |
| Reversible | Yes, via restore — but a reason is required and the act is audited | Yes, freely — an ordinary toggle |
| On-chain effect | Mirrors an on-chain pause() so an archived pool cannot take a deposit through a direct contract call | None |
| Lifecycle | Untouched | Untouched |
| Badge | Shadows the lifecycle chip as Archived | Shown 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
| State | Deposit | Withdraw / Redeem | Claim yield | Reversible? | Meaning |
|---|---|---|---|---|---|
ACTIVE | ✅ | ✅ | ✅ | — | Normal |
ACTIVE + writedown | ✅ | ✅ | ✅ | — | nav_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 toggle | Capital 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 → claim | — | Terminal / irreversible | Liquidation |
Frozen (is_emergency_frozen) | ❌ (whole freeze) | ❌ first 72h, then ✅ | ❌ first 72h, then ✅ | Time-bounded emergency | Everything halted, asymmetrically — see below |
MATURED | ❌ | ✅ penalty-free | ✅ | Terminal | Term complete |
CLOSED | ❌ | ✅ (existing holders) | ✅ | — | No longer raising |
UPCOMING | ❌ | — (no positions yet) | — | → ACTIVE | Not 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 toggle | Removed 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:
| Action | Sets | Auto-clears |
|---|---|---|
| Pause | is_paused = true | — (lowest) |
| Impairment (execute) | lifecycle = IMPAIRED | is_paused (IMPAIRED already blocks deposits) + on-chain unpause() |
| Freeze | is_emergency_frozen = true | is_paused + on-chain unpause() |
| Wind-down (execute) | lifecycle = WIND_DOWN | is_paused + on-chain unpause() — and does not set is_emergency_frozen (see below) |
Close (POST /pools/{id}/close) | lifecycle = CLOSED | is_paused + on-chain unpause() (v3-110 D) |
Maturity / auto-close (pools.scheduler.lifecycle, on end_date and subscription_end_date respectively) | lifecycle = MATURED or CLOSED | is_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 revertingwhenNotPausedinvisibly (admin + investor UI show the pool as open),- a later
pause()revertsEnforcedPause()→ the endpoint 502s with no recovery path in the UI (the toggle offers pause, not unpause, because the DB saysis_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:
- 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 nowhenNotFrozen/whenActiveguard, so it works after the escalation has landed (onlypause()is guarded). - The DB mirrors the chain, not the intent.
is_paused = falseis written only if the contract is actually unpaused. If the unpause tx fails the column staystrue— 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 aspause_clear_failed: true; a successful lift recordsunpause_tx_hash. - 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 for | Mechanism | |
|---|---|---|
| Deposits (capital in) | the whole freeze | deposit() whenNotFrozen |
| Withdrawals + yield claims (value out) | the first 72h only (FREEZE_EXIT_WINDOW from freeze_started_at) | PoolCommonLib.checkExitNotBlocked |
| The freeze itself | expires after 7 days (FREEZE_MAX_DURATION) with no transaction — _frozenActive simply reads false | so 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.