Notification System
How the platform notifies investors, fund managers, operators, and admins — channels, events, recipients, copy, isolation, and the email/in-app delivery model. V1 confirmed 2026-06-25 (decisions v3-44 / v3-45); rebuilt on the event → notification → delivery model 2026-07-31 (v3-103, migration 0110).
SoT note: This page is the comprehensive notification reference, and the code is the source of truth for the copy itself (v3-103):
apps/infra/lib/shared/notifications/catalog/(one file per sheet section since 0110). The Notification Copy sheet is the product-facing view of it — where the two disagree, the code wins, and the durable fix is to generate the sheet from the catalog rather than hand-editing both. That is not licence to write copy here: an entry with no authored text is aTODO(copy)and a question for product (CLAUDE.mdrule 2-b), and the build guard fails on invented guarantees and fabricated dates.
Channels (V1)
| Channel | Status | Notes |
|---|---|---|
| In-app | ✅ V1 — always-on baseline | Every notification appears in the app (bell + /notifications). Not a per-event toggle. A notifications row IS the in-app item, so it exists regardless of what happens to the email. |
| ✅ V1 | Transactional, EN-only. From no-reply@aset.finance. Critical always sent; optional respects preferences for both audiences. A suppressed address (hard bounce / complaint) overrides even critical. | |
| Webhook | ⏳ scaffold | notification_channel enum value (migration 0087) reserved for programmatic delivery. Adding it is now one more notification_deliveries row per notification — not a duplicate of the payload. |
| Telegram / Slack | ⏳ V2 | Channel columns added when introduced. |
Architecture
Three tables, one question each: what happened / who is told / how it went out.
domain handler | indexer writer
→ notify({ eventKey, idempotencyKey, subject, audience, variables, perRecipient? })
1. catalog lookup (catalog/, one entry per event)
2. notification_events INSERT ← idempotency_key UNIQUE = the dedup
3. audience resolve → PEOPLE (audience/resolve.ts)
4. per-person channel decision (preferences/decide.ts, pure)
5. notifications + notification_deliveries INSERT
6. PENDING deliveries → SQS
→ in-app feed reads `notifications` (GET /notifications · /admin/notifications)
→ notifications.worker.deliver (SQS) renders the email NOW → SES → SENT
→ notifications.webhook.ses (SNS) bounce/complaint → email_suppressions
→ notifications.scheduler.sweep (5 min) re-enqueues anything strandednotify() is the only entry point. It is non-fatal by contract: every producer rides on an operation that has already committed, so a notification must never turn a settled deposit into a 500. Failures are logged and reported through the return value.
Why the three tables
The previous model was one notification_logs row doing two jobs — the in-app inbox item and the email delivery log. Everything awkward about it came from that overlap:
| Symptom | Cause |
|---|---|
An in-app item the investor could read carried status = 'FAILED' | The status described the email |
SUPPRESSED had to be invented | To mean "email skipped, in-app fine" — a state that only exists when one row spans both |
The WEBHOOK enum value was unusable | One channel column per row, so a second channel meant duplicating the payload |
An FM notice for a pool with no fund_id reached nobody, silently | recipient_id meant user_id / pool id / fund id depending on the row, and the send worker resolved it 80 lines later |
Produce-time fan-out
An ADMIN notification used to be a single broadcast row whose audience was resolved when the email was sent. It is now one notifications row per person, written by the producer. Three consequences, all intended:
- Read state is per person. A teammate opening an alert no longer clears it for the whole team (this replaces "model A: shared read").
- Preferences apply where channels are chosen, so an opt-out means no delivery row — it cannot appear in a failure count.
- An unresolvable audience fails at the call site instead of disappearing at send time.
It also deleted the admin feed's fund-isolation logic outright: an FM cannot see another fund's rows because those rows are addressed to other people.
Audience lives in the catalog
Each entry declares its own recipients:
{ eventKey: 'yield_distributed_ops', audience: ['opsTeam', 'fundManagers'], … }investor · poolHolders · poolFollowers · fundManagers · opsTeam · adminUser. Previously "who receives this" was decided by which helper a producer happened to call, so the sheet's recipient column recorded an intention the code could contradict — and did (two NAV events were documented as reaching ops but only ever went to investors). A producer now passes only the parameters ({ poolId }), never the audience.
Idempotency instead of dedup lookups
notification_events.idempotency_key is UNIQUE, and that is the whole mechanism. It replaced four separate fail-open lookups (dedupByEntity, recentlyNotified, plus two hand-rolled notification_logs queries) that each let duplicates through whenever the lookup itself errored.
Recurring alerts put their cadence in the key rather than passing a time window:
| Shape | Example | Why |
|---|---|---|
| Once per entity | deposit_confirmed:<depositId> | The event happens once per row |
| Once per on-chain log | deposit_confirmed:tx:<hash>:<logIndex> | An indexer replay must not re-notify |
| Once per cycle | freeze_expired:<poolId>:<freezeStartedAt> | An extension restamps it, so it re-notifies legitimately |
| Daily while true | epoch_gated:<pool>:<epoch>:2026-07-31 | The condition persists; hourly cron must not spam |
Render timing is different per channel, on purpose
- In-app renders at write time and is stored. The feed query then does not depend on the catalog, and editing copy does not rewrite a message someone already read.
- Email renders at send time from the catalog plus the stored variables. A copy fix therefore reaches anything still queued or being retried, and a thousand-holder distribution stores a thousand rows of variables instead of a thousand copies of the same HTML document.
⚠️ In-app copy is a snapshot, so a copy fix is not retroactive. dispatch() calls renderInApp(def, vars) at insert time and stores the result on notifications.in_app; the feed selects that column and returns it verbatim. Rows written before a copy change keep the old wording forever, and the only way to see a fix is to trigger a fresh notification. Verifying one on an existing feed row will always look like the change did not ship. (The card's stat block is the exception — it is read live from notification_events.variables, so an older event simply has no newNav / issueValue and the block is omitted rather than rendered wrong.)
Numbers in copy are formatted by the producer, never by the template
A template is a string substitution: '{oldNav} → {newNav}' is an email hero and a detail row, so whatever the producer passes is exactly what the recipient reads. nav_change_proposed passed the raw NUMERIC column and shipped
0.9166666666666666 → 0.85to investors. Nothing catches this — the number is a valid number, the substitution succeeds, tsc has no opinion about how many digits a NAV has, and the copy guard checks wording rather than values. notifications/format-nav.ts is now the one formatter ($0.9167, four decimals with the symbol) and every NAV producer goes through it: nav_change_proposed (+ _ops), nav_change_cancelled (+ _ops), nav_proposal_pending, nav_apply_failed, and the writedown notice that had privately been doing it right all along. A missing value renders the empty-value glyph rather than $0.0000, which would be a claim.
Four decimals is a display choice, not the stored precision: NAV lives at NAV_PRECISION = 1e6 on-chain and payouts are computed there, never from this string.
Delivery
A queue, not a cron. The old worker polled every 3 minutes for a batch of 100 and used a claimed_at column to stop overlapping runs double-sending; SQS provides that (an in-flight message is invisible to other consumers) and removes the ~2,000/hour ceiling that made a thousand-holder distribution take half an hour to drain.
- Retry belongs to the queue. A failed message returns as a batch-item failure, SQS redelivers after the visibility timeout, and the redrive policy sends it to the DLQ after 5 attempts.
attempts/next_attempt_aton the row are the record of that and what the sweep reads. - Backoff exists now: 1 → 5 → 15 → 60 → 240 minutes. 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.
SENTmeans SES accepted it, which is not the same as delivered — see below.
Bounce and complaint feedback
SendEmail succeeding only means SES took the message. The bounce or complaint arrives minutes later on an SNS topic, and nothing listened before 0110: the configuration set existed, notification_failure_type already had BOUNCED / SPAM_FILTERED, and SendEmailCommand never named the set — so every bounce SES generated was discarded, and DELIVERED was a claim the system could not support.
notifications.webhook.sescorrelates feedback byprovider_message_id(recorded at send) and flips the delivery toBOUNCED/COMPLAINED.- A permanent bounce or any complaint adds the address to
email_suppressions. A transient bounce does not — a full mailbox should not silence an investor forever. - A suppressed address outranks priority, including critical: the message would not arrive anyway, and continuing damages a sending reputation shared by every email the platform sends. The in-app notification still lands.
⚠️ Deploy step: attach the SesFeedbackTopicArn stack output as a Bounce + Complaint event destination on the aset-<stage> SES configuration set. Until that is wired, feedback is still discarded.
Managing the list. A suppression outranks everything, including critical events, so an address on it stops receiving redemption and compliance notices entirely. Most entries are correct, but a receiving server misreporting a transient failure as permanent — or a recipient who clicked "spam" and changed their mind — would otherwise be cut off with no way back, which is why the list is not write-only:
GET /email-suppressions | Read the list (address, reason, provider diagnostic, when). Gated like the notification log: OPERATOR needs the notifications page permission, ADMIN/SUPER bypass |
POST /email-suppressions | Suppress an address by hand (reason = 'MANUAL'). ADMIN / SUPER_ADMIN only, note required. For a request to stop emailing someone outright — which the per-event toggles cannot express — a known-bad address, or a compliance hold. Audited as EMAIL_SUPPRESSION_ADD |
DELETE /email-suppressions/{email} | Lift a suppression. ADMIN / SUPER_ADMIN only — restoring email to someone who reported us as spam has reputational and compliance weight, so it is not an operator action. Audited as EMAIL_SUPPRESSION_REMOVE, and the audit record keeps the prior reason and diagnostic because deleting the row destroys them |
reason is not a parameter on the manual endpoint. HARD_BOUNCE and COMPLAINT are statements about what the provider observed, and an admin cannot observe them — accepting either would put a claim in the audit trail that nothing witnessed. Adding an address that is already suppressed is a 409 carrying the existing reason, not a silent success: "already suppressed by a hard bounce" is different information from "I just added it", and the stronger provider-observed reason is usually the one to keep.
Both write directions are audited because this is the one control that overrides priority: a blanket block stops payout and compliance notices too. Turning off one event for one person is what the preference API is for.
Lifting a suppression does not retry anything already SKIPPED: those were decided against at the time, and re-sending an old notice on an unrelated admin action would surprise the recipient. The next occurrence of the event reaches them normally.
⚠️ Open product/legal question: a COMPLAINT is the recipient's own expressed wish, which differs in kind from clearing a stale bounce. The endpoint currently allows both and records which it was. If complaints should be irreversible, that is a policy decision to make rather than something to infer in code.
Recipients & isolation
notifications.recipient_kind ∈ INVESTOR | ADMIN, always a person — never a pool, fund, or "the team". INVESTOR points at users.id, ADMIN at admin_users.id (which covers fund managers: an FM is an admin_users row with role FUND_MANAGER). The two values match notification_preferences.user_type exactly, so a preference lookup is a plain equality with no mapping layer.
| Boundary | Isolated? |
|---|---|
| Different-fund FM ↔ FM | ✅ Structurally — rows are addressed to individuals, so there is nothing to scope. The endpoint filters on recipient_id = me. |
| FM ↔ admin/operator | ✅ Separate rows per person |
| admin ↔ operator | ⚠️ Both are in the opsTeam audience, so both receive ops events. Each has their own row and own read state. Splitting them is deliberately deferred (v3-103): it needs a per-event audience decision across every admin-app key, and no operator-only event exists yet to pay for it. |
An event targeting two audiences (['opsTeam', 'fundManagers']) writes one row per person across the union, de-duplicated — someone who is both an operator and a fund member hears about it once.
Critical vs optional
- Critical (D4): material events (NAV writedown / impairment / wind-down), the redemption flow, account safety (freeze, KYC revoke), and most money/identity events. Always sent; cannot be disabled.
- Optional: operational/awareness events — the
*_opsvariants, the epoch operations alerts,large_deposit, the yield-due reminders, the team/access changes, andpool_lifecycle_active. Respectnotification_preferences. 39 of the 66 keys target the admin app and 27 the investor app. 19 are optional, and 16 of those are admin-app — so only three optional events reach an investor:pool_lifecycle_active,pool_update_importantandepoch_request_window_open. (Counts fromcatalog/'s ownapp/priorityfields, 2026-08-06.) - Severity (visual accent, separate from priority):
info(brand blue) for routine/positive,critical(coral) for failures/adverse. Stored per event in the copy registry. - Throttle (D8): critical = immediate, one per event; optional = 1h digest window (V2, not built). Retry: SQS redelivery with 1/5/15/60/240-minute backoff, then the DLQ; on terminal failure the worker emits
fm_notification_failedto the ops team. - Enforcement is a pure function (
preferences/decide.ts) fed batch-loaded facts, so the rule is unit-tested without a database. Precedence: no address →NO_ADDRESS; optional + opted out →OPTED_OUT; suppressed address →SUPPRESSED_ADDRESS(beats critical). Each is recorded as aSKIPPEDdelivery so "we deliberately did not email this person, and why" is answerable.
Event inventory (V1)
66 entries in catalog/, verified against the producer map 2026-08-06 by the build guard. Grouped by the registry's own category, which is also the FE filter and both preference catalogues. FM/operator awareness variants (*_ops) are sent in addition to the investor notice (D3). ⛔ = authored copy with no producer (see below).
| Category (n) | Events | Recipient |
|---|---|---|
| DEPOSIT (3) | deposit_confirmed · deposit_confirmed_ops · ⛔ lp_minted | Investor (2) · FM/Admin (1) |
| REDEMPTION (20) | redemption_requested · redemption_completed · redemption_returned_unfunded / redemption_rejected (split by reason) · redemption_pending_reserve · epoch_settlement_complete · epoch_request_window_open · epoch_demand_finalized · over_funding_detected · fm_shortfall · redemption_escalated · redemption_exit_gate_blocked · epoch_funding_needed · epoch_partner_funding_reminder_48h / _12h · epoch_circuit_breaker · epoch_execute_failed · epoch_gated · epoch_low_fill · ⛔ redemption_funded | Investor (7) · FM/Admin (14) |
| YIELD (6) | yield_distributed · yield_distributed_ops · yield_distribution_due · yield_distribution_overdue · yield_distribution_stalled · yield_distribution_escalation | Investor (1) · FM/Admin (5) |
| POOL (25) | pool_matured (+ _ops) · impairment_executed (+ _ops) · winddown_executed (+ _ops) · impairment_proposed_ops · winddown_proposed_ops · impairment_execute_due · winddown_execute_due (governance timelock) · nav_change_proposed (+ _ops) · nav_change_cancelled (+ _ops) · nav_proposal_pending · nav_apply_failed · pool_frozen · freeze_exit_window_open · freeze_expired · pool_unfrozen · pool_lifecycle_active · epoch_funding_date_due · large_deposit · pool_update_important / pool_update_material (the WO-6 wrapper, below) | Investor (10) · FM/Admin (15) |
| KYC (4) | kyc_approved · kyc_rejected · kyc_reverify_due · sbt_mint_failed_user | Investor |
| ACCOUNT (4) | kyc_revoked_aml · fund_invite_accepted · fund_member_changed · admin_user_changed | Investor (1) · FM/Admin (3) |
| SYSTEM (3) | sbt_mint_failed · fm_notification_failed · ⛔ lp_mint_failed | Admin |
| Pool updates (WO-6) | manager/system announcements. Counted in POOL above since 0110: pool_update_important / pool_update_material are real registry entries holding the wrapper the platform puts around operator-written text, and the title and body arrive as variables. INFO is feed-only and sends nothing. | Holders |
freeze_extended is gone from the registry — the on-chain extension itself was removed, because its 7-day timelock equalled the 7-day freeze lifetime, so it could only fire after the freeze had already lapsed or retroactively re-lock the pool and restart the 72h exit block. Its email was wrong anyway: it rendered the new start date under an "Until" label. And pool_nav_update, which the old 13-operations matrix listed, was never an event key at all — the real ones are nav_change_proposed / _cancelled (+ _ops), nav_proposal_pending and nav_apply_failed.
In-app categories (FE filters): DEPOSIT · REDEMPTION · YIELD · KYC · POOL · ACCOUNT · SYSTEM. The set is closed on purpose — it feeds the FE filters and both preference catalogues, so adding a category is a three-surface change, which is why holdback_release_deferred is REDEMPTION rather than a new FUNDING.
The three entries with no producer
lp_minted— intentional. The v3-48 deposit is atomic, sodeposit_confirmedalready tells the holder their tokens exist; a second notice for the same transaction is duplication. Declared incatalog/index.tsUNEMITTED, so the settings page no longer lists "LP Tokens Issued" as something the investor will receive.redemption_funded— authored, never wired.redemption_pending_reservealready promises a notice when the payout lands andredemption_completed/epoch_settlement_completedelivers it, so the intermediate "funded, now processing" step has no reader.lp_mint_failed— unobservable under the atomic deposit: a reverteddeposit()leaves no row, no event and no partial state, so nothing can detect it. Reviving it needs a client-reported failure endpoint, which is a product decision rather than a missing producer. Declared incatalog/index.tsUNEMITTEDand rendered disabled ("Not yet active") in the panel.
redemption_funded and lp_mint_failed are slated for deletion (v3-103): the copy sheet already dropped both, so the code is asserting events the product does not have. lp_minted stays.
Every producer now goes through the registry
Eight events used to write notification_logs directly, leaving payload null so the in-app card rendered one long sentence as its title with no body, no CTA and a SYSTEM miscategorisation — epoch_funding_needed, both partner-funding reminders, nav_apply_failed, epoch_circuit_breaker, epoch_execute_failed, epoch_gated, epoch_low_fill. All eight now route through queueNotification, and the epoch ones were recategorised SYSTEM → REDEMPTION.
The epoch-execute admin alerts also dedupe per (pool, event_type) per day. The handler is an hourly cron reporting sticky states, so a tripped circuit breaker had been mailing every admin 24× a day — and the comment claiming idempotency had no query behind it.
create-system-update.ts still inserts directly, but writes a full payload — and it no longer hardcodes priority: 'optional': MATERIAL_EVENT is forced to critical, so an impairment or wind-down notice cannot become opt-out-able now that investor enforcement is live.
A build gate keeps this list honest
scripts/check-notification-producers.mjs (wired into infra build + test) fails the build on all three drift shapes: copy with no producer, a producer emitting a key the registry lacks, and an EVENTS_WITHOUT_PRODUCER declaration that has become untrue. Keys match as quoted string literals — a substring scan reports lp_minted as produced because the column lp_minted_at contains it, which is exactly what made an earlier hand audit wrong. Runtime-assembled keys are declared in DYNAMICALLY_KEYED with their prefix, and the prefix is still checked against the named file. check-copy.mjs also covers infra now, so notification strings sit behind the same copy guard as the two front-ends.
⚠️ infra has no CI workflow: the gate runs on pnpm build / pnpm test only, and cdk deploy does not go through build. Run pnpm --filter @aset/infra test before deploying.
Closure notices are split by reason (v3-99)
Return position (POST /redemption-requests/{id}/reject) closes a request for two very different reasons, so it sends two different notices. The failure_type category the operator picks is what selects them.
failure_type | Event | Investor reads |
|---|---|---|
UNFUNDED | redemption_returned_unfunded | "Redemption closed, position returned" — funding did not arrive, nothing was deducted, request again any time |
COMPLIANCE / OTHER | redemption_rejected | "Redemption on hold" — position is back, contact support before requesting again |
One notice could not cover both: funding never arriving is no fault of the investor, so the old "declined" wording was simply wrong there, and neither wording mentioned the fact that matters most in both cases — the LP came back. Both notices lead with the returned position ({lpAmount} {lpSymbol}).
The operator's note is never rendered
The mandatory free-text reason is written for a later reviewer ("partner unfunded 3 weeks, confirmed with FM") and can carry detail that should not leave the admin panel, so it is not passed to either notice. The category picks the wording instead.
The compliance notice also withholds why: naming an AML or verification trigger to the subject risks tipping-off. Wording is pending legal sign-off (Notion "Legal Review Required" #25) — the branching and the vars are final, only the strings may change.
redemption_exit_gate_blocked — admin-only, and not an alarm about money (v3-99)
The on-chain gate refuses to pay a holder whose eligibility lapsed while their request waited, but that revert is silent to Aset: the partner sees a failed transaction and we see nothing. An hourly sweep over PENDING_RESERVE requests closes that blind spot by alerting an admin.
- Recipient: ADMIN only. Nothing has happened to the investor yet — no payout was attempted, no state changed — so there is nothing to tell them.
- Not a funds-at-risk notice. The copy says the request cannot be paid out / will not settle, never "frozen" or "at risk", because the contract blocks the payout: nothing leaves the pool. It also states that closing the request returns the position, which is what
rejectRedemptionactually does. - No dates, no review deadline, no promised next step — nothing in the system schedules one, and inventing one is the failure mode
CLAUDE.mdrule 2-b exists to prevent. - Re-alerts daily, not hourly. The condition is sticky (a revoked SBT stays revoked), so the alert dedups per request within 24h: keyed on the request, not the pool, because two blocked holders in one pool are two separate decisions.
The closure dialog reads the gate live, not this alert
The COMPLIANCE pre-select is now wired, but to a read-only GET /redemption-requests/{id}/exit-gate called when the dialog opens — not to the sweep's alert. A pre-select taken from an hourly sweep could be an hour stale and would be absent entirely 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: a wrong guess here mislabels a funding closure as a compliance one, and that label is what picks the investor's notice.
holdback_release_deferred — retired with the lever it described (v3-100 → 0183)
This notice told a fund manager that part of a hold-back release had been deferred because it still backed settled-but-unclaimed redemption claims. It never sent: the event it listened for, FundingRestrictedSet(restricted=false), could not fire, because nothing in the product called setFundingRestricted — which is why the entry sat ⏸️ ON HOLD in the catalog with provisional wording.
Removed in 0183 with the lever, the bucket, the indexer writer and the clamp-decision module (v3-112). The catalog entry and its producer are gone, so the notification-producer guard is satisfied by their absence rather than by an exception.
Worth keeping in mind rather than the mechanics: this entry is where the automatically correction came from. The shipped copy promised the remainder would pay out "automatically once those claims are collected", and a unit test asserted /automatically/ — which is why the false claim survived review. Nothing re-ran the release, so the sentence described a mechanism that did not exist: a CLAUDE.md rule 2-b defect in live copy, caught only by reading GovernanceLib against the string. The lesson outlived the feature.
Amounts from the pool's counters are 18-decimal, not USDC's 6
The pool normalizes every USD figure to 18 places (PoolCommonLib.normalizeAmount), so reserveBalance, epochFundTopUp and anything derived from them are 18-decimal. Formatting one with USDC_DECIMALS overstates it by 1e12 — and a mis-scaled amount still renders as a plausible number, which is how it survives review.
Fixed in the same change for four notice paths that had it wrong: epoch_funding_needed (and the funding_shortfall column behind the "Awaiting Funding" KPI), epoch_settlement_complete's payout (also redemption_requests.payout_amount + every redemption_fills row), redemption_pending_reserve's shortfall, and the D-2/D-12h partner-funding reminders — whose shortfall computed as ~0 on any real pool, so they never fired at all.
The axis is now a type, not a naming convention (v3-101): RawAmount / NormalizedAmount / NavPrice in contract/units.ts, declared on every on-chain amount parameter, with scripts/check-onchain-units.mjs banning parseUnits/formatUnits outside the converter modules. Applying it surfaced two more live 1e12 instances that no notice had caught, because every on-chain check on these values is an upper bound, so a too-small number passes and the transaction succeeds. Anything reading a pool counter for a notice must go through the converters rather than reaching for a decimals constant.
pool_lifecycle_active — the first optional investor event, and why it needed a table
Its copy reads "A pool you follow is open" / "You asked to be notified when it opened". That sentence is only true of someone who asked, so the recipient list has to be an explicit opt-in — sending it to every investor would make the copy false for every recipient and turn a product notice into an unsolicited new-pool advert. That is why the event had been parked as V2: not the producer, the list.
pool_follows(migration 0109) is that list — one row per(user, pool)as the PK, so a re-follow is an idempotent upsert and an unfollow is a delete. No status column: a "was following" history has no reader and would only invite sending to it.ON DELETE CASCADEon both FKs; because pools are normally soft-deleted (which CASCADE does not see), the producer filters on the pool it is already iterating and the list endpoint joins through to a live pool.- Producer:
pools.scheduler.lifecycleat theUPCOMING → ACTIVEtransition, non-fatal like every other producer there — a notification must never roll back a lifecycle change that already landed on-chain. - Endpoints:
GET/POST/DELETE /pool-follows; the follow control sits on the UPCOMING banner. - This is the first
optionalinvestor event in the registry, which is what makes investor preference enforcement observable at all — before it,TOGGLEABLE_INVESTOR_EVENT_KEYSwas empty and the gate had nothing to gate.
epoch_funding_date_due — the only thing watching for a cycle nobody has dated
The anchored epoch schedule derives every boundary from one number per cycle: the funding date the publisher gives the operator off-chain. Decision C2 made that fail-open — an unconfirmed cycle advances on its own to previous funding date + cadence — so nothing breaks when the operator is silent. What breaks is the opportunity: before windowOpen(n), setEpochFundingDate is free; after it, the call reverts WindowAlreadyOpen and the cycle is locked to the fail-open date, with settlement only delayable up to funding date + recall_lead_days.
So an hourly sweep (pools.scheduler.epoch-funding-date) nudges once per (pool, cycle) inside a lead window before the request window opens. Read-only apart from the notification tables it writes through notify().
⚠️ It used to be gated on epoch_cycle_mode — SEMI_AUTO opted into the nudge, AUTO opted out — and that gate is gone (v3-140). AUTO turned off the only thing watching for a cycle nobody had dated and appointed no replacement, so the pool fell through to the chain's previous + cadence derivation with its one warning signal disabled. The column is kept but unread (0192, comment-only).
On a post-maturity repayment pool the gate is state, not preference (v3-135)
A repayment plan has a known, finite list of funding dates, written by the deploy worker under a clock (v3-133). That run can be cut short, and what it leaves behind does not read as missing on-chain — epochFundingDateAt derives it as previous + cadence and states it as confidently as a real date. So the honest question is not whether the operator asked to be reminded but whether any cycle is still deriving its date:
Any cycle unwritten → remind. Every cycle written → silent. Gated on
redemption_epochs.funding_date IS NULL(0190), throughunwrittenPlanCycles— the same function the epoch summary uses, so the reminder and the admin screen cannot disagree about what an incomplete plan is.
Two other things changed with it, and both matter more than the gate:
- The date the notice names comes from the stored rule, walked from the funding anchor, not from
previous + cadence. On a roll-15 plan the old derivation proposed the 12th — telling the operator to confirm a date the plan never contained, which is why the reminder had to be switched off for these pools rather than fixed. It is fixed here instead, and it is the "human-facing suggestion" v3-136 permits: proposing a date for someone to confirm is allowed, writing one is not. - Dedupe is keyed on the set of unwritten cycles, not on a cycle date, because the ask is "finish the list". It re-arms only when that set changes; a key on the current cycle would repeat hourly while the list stayed incomplete.
🔴 Until the confirmation card is deployed, this notification is the only recovery path
The deploy does not retry — deploy_status is already DEPLOYED when the writes run, so a redelivered message is turned away by the idempotency guard and never reaches them. Nothing else watches for a truncated plan. Whoever later decides this is "just a notification" and gates it off removes the last thing standing between an incomplete list and a pool paying on dates its operator never approved.
✅ Fixed the next day by v3-140. As first committed the sweep evaluated epoch_cycle_mode !== 'SEMI_AUTO' → skip before it reached the state gate while the wizard shipped this combination as AUTO, so the recovery path above did not run on the pools it was written for. The mode check is now gone from the gating entirely, the wizard and pool-edit no longer carry the field, and migration 0192 marks the column unread. Still not deployed and 0192 is not applied.
The sweep now has two branches and no mode check, asking one question — is there a cycle whose payout date nobody set? — from two different kinds of evidence:
| Pool shape | Evidence |
|---|---|
Repayment plan (redemption_term_epochs set) | The set difference against redemption_epochs.funding_date. No clock involved — the list is finite and either complete or not |
| On-request epoch | The calendar. A row appears only when its cycle arrives, so "cycle N+1 has no date" and "cycle N+1 does not exist yet" are indistinguishable from the table, and the lead window before windowOpen is what remains |
🔴 over_funding_detected tells fund managers the opposite of what the contract does
This one is live copy, not a plan. The event fires to fundManagers after any settlement that leaves a surplus (lib/money/usecases/epoch.ts), and its body reads:
"The extra {excessAmount} {currency} carries over to the next epoch, so no action is needed." —
catalog/redemption.ts:319-324, withemailPreheader: 'Excess funding carried to next epoch'and an "Excess carried" detail row.
Both halves are false, and the second is the expensive one. epochFundTopUp is keyed by cycle and a settlement reads only its own key (RedemptionLib.sol:955); reserveBalance += exists in exactly one place in the repository (PoolLedgerLib.sol:73), so nothing moves a top-up anywhere (v3-137). Because the surplus is not available to the next cycle, the correct instruction is that the next cycle must be funded in full again — the precise opposite of "no action is needed". On a repayment plan that misdirection repeats every cycle.
Two things about how it survived are worth more than the fix:
- The comment above the entry reasons its way to the wrong conclusion while citing real line numbers, and rests on the same v3-93 C10 misreading that sat in
epoch-redemption.tsuntil it was corrected. Anything else citing v3-93 for carry-forward behaviour is suspect on the same grounds. catalog-integrity.test.ts:232asserted/carries over/i, so the sentence was pinned by a test — the identical mechanism that let v3-96's fabricated "automatically" survive review. The fix had to move the assertion, which is the point of having one.
✅ Fixed 2026-08-18. The body now names the cycle that holds the money and says the next one cannot draw on it; the preheader and the Excess carried detail row went with it. catalog-integrity.test.ts lost the /carries over/i assertion and gained one that fails if carry-forward wording returns, scoped to this event key so it cannot sweep up the demand-side copy. Copy, assertion and this page moved in one commit, because correcting the page and leaving the mail is worse than leaving both wrong: it makes the defect invisible to the next reader.
⚠️ Do not "fix" this by deleting the carry language everywhere: unfilled demand really does carry (
epochCarryDemandLp, served before new demand), and the investor-facing copy that says so is correct. It is funding that does not carry.
- Recipient ADMIN,
priority: critical,severity: info. Nothing has gone wrong; a window is about to close. - Dedupe is per cycle, keyed on rows created since the lead window opened —
dedupByEntitywould silence every cycle after the first. - ✅
LEAD_DAYSis 7. v3-100 raised it from 3 — three days is not enough for a partner to confirm a settlement date. Shipped with the notification pipeline rebuild (2026-07-31). - Its four variables (
windowOpensAt,fallbackFundingDate,settleByLatest,poolName) exist in code but the event has no row in the copy sheet yet — see the drift note under Status.
Governance timelocks: start and end, with nobody watching
A NAV decrease applies by itself — nav-changes.scheduler.apply-pending is a scheduled lambda that calls applyPendingNavOnChain when the 24h timelock clears. Impairment and wind-down do not. executeImpairmentOnChain / executeWindDownOnChain have exactly one caller each, the action === 'execute' branch of their own POST handler, and both handlers are registered with HTTP paths:, never a schedule:. Everyone who has looked at this has assumed otherwise, which is what the four events below exist to stop.
| Moment | Event | Audience | What it says |
|---|---|---|---|
| Timelock starts | impairment_proposed_ops · winddown_proposed_ops | opsTeam + fundManagers | the objection window is open, and how long it runs |
| Timelock about to end | impairment_execute_due · winddown_execute_due | opsTeam | nothing runs on its own; execute it |
Propose (_ops). Before these, a timelock could start with the investor card as the only push signal. That left the two people with something to do unnotified: the FM, whose fund is the subject and whose only recourse is to object off-platform before it executes, and the operator, who has to run the execute by hand. NAV's propose stage was already covered (v3-97 decision B) and is unchanged. fundManagers is pool-scoped: resolveFundId reads pools.fund_id, then filters fund_members on that fund with status = 'ACTIVE'. Keyed on the on-chain tx, so re-proposing after a cancel is its own notice. app: 'admin' + /pools/{poolId}, matching every other _ops entry.
🔴 The two durations are different, and the copy states them
GOVERNANCE_TIMELOCK = 7 days (impairment) · WIND_DOWN_TIMELOCK = 30 days (wind-down) — PlatformPool.sol:208,211. The copy sheet said 7 for both, it was mirrored verbatim, and winddown_proposed_ops shipped "A 7-day timelock applies" to the fund manager: a 23-day understatement, on the one notice whose entire purpose is to say when to object. Both front-ends already had 7/30 right; the catalog was the only place wrong. Now pinned by a test asserting each entry states its own duration and not the other one, across in-app summary, in-app body, email body and preheader.
The number lived in four places with no shared constant. The two BE handlers now import business/governance-timelock.ts; copy still spells it out as words and cannot import a number, which is what the test is for. The *_execute_due pair sidesteps the problem entirely by rendering the computed expiry instead of naming a duration.
Execute-due. An hourly sweep (pools.scheduler.governance-execute-due) over pools with a pending proposal, decision in business/governance-execute-due.ts. opsTeam only — executing is an operator action, and the FM's stake was the objection window the propose notice already gave them. Lead time is D-1 for impairment (a 7-day window is short) and D-3 for wind-down.
- No upper bound on the window, on purpose. A window only as wide as the lead period would be one cron period for impairment, so one failed run would drop the only nudge that proposal ever gets — and a proposal that sailed past its expiry with nobody acting is precisely the case worth catching. Firing once is guaranteed by the idempotency key (
{event}:{poolId}:{proposedAt}), which also re-arms on a cancel-then-repropose. - It skips a proposal the contract would now refuse.
executeImpairmentre-checkslifecycleStatus == ACTIVEat execution (PlatformPool.sol:1287), so on a pool that has moved on the button can only revert.WIND_DOWNis terminal for both. - It executes nothing. Impairment is an irreversible status change and wind-down is terminal; neither should happen because a cron woke up.
- ⚠️ It reads
pools.impairment_proposed_at/wind_down_proposed_at, so a proposal placed directly on-chain (a multisig callingproposeImpairment) is invisible to it.ImpairmentProposed/WindDownProposedare not watched by the indexer; the execute side now is (below).
A third-party execute now reaches the DB
executeWindDown() carries no role modifier (PlatformPool.sol:1330) and that is deliberate, not a bug: the docs specify "Timelock passes → anyone can call Pool.executeWindDown()", the standard pattern where anyone may trigger an already-approved, timelocked action. Propose and cancel are admin-gated and the executor cannot alter any parameter. Do not add a role modifier — it would break the documented liveness design.
What was missing was the mirror. The indexer watched neither ImpairmentExecuted nor WindDownExecuted nor LifecycleStatusChanged, and the only writer of lifecycle_status = WIND_DOWN was the BE execute branch. So an outsider could enact a wind-down on-chain and leave the pool reading ACTIVE in the DB, with the UI offering deposits the contract reverts — the v3-92 defect class, and with a permissionless path, not a hypothetical one. All three events are now watched (indexer/writers/governance.ts):
ImpairmentExecuted→IMPAIRED, clearsimpairment_proposed_at.WindDownExecuted(navPerToken)→WIND_DOWN, recordswind_down_executed_atfrom the block timestamp, clears the proposal flag, mirrors the pro-rata NAV.LifecycleStatusChanged(old, new)is the generic mirror behind both, and the only signal for a baresetLifecycleStatusplaced straight on-chain. It notifies nothing; the two above own that.- Both fire
*_executed+*_executed_opsusing the BE handlers' exact idempotency keys. That is the dedup: through the API the event row already exists andnotify()no-ops; otherwise the indexer is the only sender. Keying on the log would double-send. - ⚠️
is_pausedis deliberately untouched. The handlers clear it by sending anunpause()and writing the column only if that succeeded (v3-92). A third-party execute sends no such transaction, so the pool is stillpaused()on-chain and the mirror must keep saying so. - ⚠️ No
pool_updatesfeed entry on the third-party path.createSystemPoolUpdatehas no idempotency key, so calling it from the indexer would append a duplicate every time the BE path ran. The notification carries the news; the durable feed entry is the residual gap.
Freeze notifications (v3-97)
The v3-28 freeze is asymmetric, so one "frozen" notice cannot describe it. Deposits are blocked for the whole freeze; withdrawals and yield claims are blocked only for the first 72h (FREEZE_EXIT_WINDOW); the whole freeze auto-expires at 7 days (FREEZE_MAX_DURATION) with no transaction and no event.
| Milestone | Event | Trigger | Built? |
|---|---|---|---|
| Freeze starts | pool_frozen | EmergencyFrozen (indexer writers/freeze.ts) | ✅ |
| +72h — value-out reopens | freeze_exit_window_open | time-based sweep (no on-chain event) | ✅ pools.scheduler.lifecycle |
| +7d — freeze auto-expires | freeze_expired | time-based sweep (no on-chain event) | ✅ same sweep |
| Manual early release | pool_unfrozen | EmergencyUnfrozen (indexer) | ✅ |
Both dates up front and a notice at each milestone (v3-97). pool_frozen states both times so an investor is never left guessing, and the two sweep events confirm each reopening — a reopening the investor cannot otherwise observe, since neither emits on-chain. freeze_expired stays distinct from pool_unfrozen so "lifted automatically" and "an operator lifted it" remain distinguishable (actor provenance).
- The auto-expiry sweep exists now.
pools.scheduler.lifecycleselectsis_emergency_frozen = truepools, clears the flag at the 7-day mark and queuesfreeze_expired, and separately queuesfreeze_exit_window_openfor pools that have passed 72h but are still frozen. That closes the live defect this section used to be blocked on — an expired freeze no longer keeps deposits locked out. The 72h asymmetry itself is derived in one place (business/freeze-window.ts); three call sites had each reimplementedfrozen && now < start + 72hwith their own fail-closed handling. - Dedupe is keyed to the freeze cycle, not the pool:
(event_type, related_entity_id = pool, created_at >= freeze_started_at)— thepartner-funding.tspattern. Do not usequeueNotification'sdedupByEntity, which is one-per-pool-forever and would silence every later freeze. - Sweep cadence sets the notice lag — hourly, so ≤1h.
formatDateTime()(explicit UTC) is what these use, notformatDate(), which truncates toYYYY-MM-DDand cannot express a 72h milestone: an investor reading a bare date tries at 09:00 for a window that opens at 02:00. Email has no browser, so it cannot localise the way the FE'smilestoneLabel()does.
The API now enforces the same 72h window it describes
POST /yield-claims used to be the one unguarded layer. A yield claim is value-out, so YieldLib.claimYield → PoolCommonLib.checkExitNotBlocked refuses it during the first 72h — but the endpoint only records a claim (the investor's own wallet sends the transaction), so a missing gate here moved no money and looked harmless. What it did instead was write a PENDING yield_claims row for a claim the chain could never settle, leaving a phantom waiting on a YieldClaimed event that never arrives. The web app already gated every entry point (isExitFrozen); the API now does too. Deliberately freeze-only: is_paused blocks capital-in only, and IMPAIRED / WIND_DOWN keep value-out open, so neither may gate a claim.
freeze_extended is retired, and so is the on-chain extension
The v3-97 table used to carry a "freeze re-based" row. The FreezeExtended event has since been removed from the contract: its 7-day timelock equalled the 7-day freeze lifetime, so an extension could only land after the freeze had already lapsed — or retroactively re-lock the pool and restart the 72h exit block, which is the thing v3-28 exists to prevent. The email went with it, and it was wrong anyway: its Until row rendered the new start date.
Email — Direction B template
One branded template (renderDirectionBEmail) fills per-event slots: severity accent bar, eyebrow chip, hero (amount for money events / status word for status events), 2-cell detail card, brand-purple CTA, logo header. All emails (notifications + fund invite + email verification) use it.
Logo: production uses a hosted PNG via
EMAIL_LOGO_URL(Gmail blocks inline SVG); falls back to a text wordmark.
Investor email registration + verification
Wallet-login investors have a synthetic placeholder email (<address>@wallet.aset.io), so they register + verify a real one. Legally-required material notices need a deliverable channel — so this is a compliance requirement, not optional.
- Investor enters their email in Settings →
POST /users/me/emailstorespending_email+ a single-use token (24 h) and sends a verification email (Verify CTA). - The link opens
/verify-email?token=…→POST /auth/verify-email(public, token-gated) → on successemail = pending_email,email_verified_at = now. - Notifications then go to that verified email. Changing the email re-triggers verification; the previous verified address keeps receiving until the new one is confirmed.
- The send worker skips synthetic
@wallet.aset.ioaddresses — investor email fires only once a real address is verified; the in-app row is always delivered.
Legal follow-ups (open)
(a) material-notice threshold + deadline (NAV % / T+N); (b) require a verified email before investing; (c) does a material notice require provable delivery (SES delivery events / SNS)? Until email is adopted, investor material notices are in-app-only — not sufficient as legal notice.
Admin notification center (model A)
A bell + feed in admin-web for admin/operator/FM, rendering payload.in_app.
- Model A (audience / shared feed): admin + operator share the
ADMINfeed; FM gets only their funds'FMrows (strict fund-scope).read_atis per-row → reading is team-shared (an ops work-queue model). Per-person read state (notification_readstable) is a V2 upgrade. - Endpoints:
GET /admin/notifications(+/unread-count,PATCH /{id}/read,/read-all) —resolveAdminFeedScopeenforces the FM fund isolation.
Preferences
Absent row = email enabled (opt-out model). category now holds the copy registry's event_key for both audiences — the namespace unification that made enforcement possible at all (migrations 0101 admin / 0108 investor).
Investor — GET/PUT /users/me/notification-preferences, enforced since 2026-07-30 (migration 0108). The catalog is derived from the event catalog (preferences/catalog.ts, app: 'web' entries), the panel renders from the API, and decideChannels() consults it at produce time. Producer-less entries are filtered out of the investor-facing list entirely rather than shown disabled — a promise of a message that never arrives is worse than silence, and it is why the page can no longer offer "LP Tokens Issued".
- Before 0108 the panel wrote two invented categories:
NAV_UPDATE, which is neither an event key nor a catalog category (the nearest real events are bothcritical, so even a correctly-keyed row could not have disabled them), andPOOL_PERFORMANCE, which was worse than inert — no weekly-performance event exists anywhere, so the switch advertised a feature that was never built. Neither can be translated to an event key, so 0108 deletes the rows rather than migrating them. Nothing about past sends changes: the worker never read investor preferences before this. pool_lifecycle_activeis the only togglable investor event today and the reason the gate is observable at all; every other investor event iscriticaland locked on.
Admin / operator / FM — GET/PUT /admin/me/notification-preferences, enforced since 2026-07-29.
- The gate is
notification_preferences.category === notification_events.event_keywith no mapping layer. The togglable set is derived from the catalog (preferences/catalog.ts→ admin-app events withpriority: 'optional'); critical keys are rejected on write rather than coerced, and the panel renders its toggles from the same catalog via the GET response'sevents. - Enforced at produce time since 0110 — fan-out writes one row per person, so the preference is applied where the channels are chosen: an opt-out produces no delivery row at all, which is why it cannot appear in a failure count and why
SUPPRESSEDwas retired. The old model had to defer this to the worker because an ADMIN row was a single broadcast whose audience was only resolved at send time. A preference-lookup failure still fails open (an unwanted email beats a missed alert). - Both
ADMINandFMrows are gated. Several optional events write one row per audience (redemption_requested, the*_opsnotices), so gating only the ADMIN row would leave an FM's switch inert. ADMIN rows match preferences onadmin_users.id; FM rows match on the lower-cased address, sincefund_memberscarries an email and no FK toadmin_users— if an FM'sfund_members.emailever differs from their admin-account email, their opt-out silently does not apply to FM rows (fail-open). - An ADMIN row with
recipient_nameset goes to that address only.recipient_idon an ADMIN row is the related entity, not a person, so a producer that means one individual passes their address asrecipient_name. Until the worker honored it those rows fell through to the team broadcast:admin_user_changed("Your role and permissions have been updated") was emailed to every admin and operator instead of the person whose access changed. Second-person copy in an admin event therefore requiresrecipient_name. The in-app row is unaffected —recipient_type='ADMIN'rows are a shared ops feed (Model A above), so in-app summaries stay third-person even when the email is addressed to one person (fund_member_changedis written that way). - Email only. The in-app row is inserted by the producer and is never gated — "In-app: always on" in My Settings is accurate.
- When the whole audience has opted out, the row is marked
SUPPRESSED(0100), notFAILED/INVALID_RECIPIENT: nothing failed, andFAILEDfeeds an ops queue. Resend requiresFAILED, so a suppressed row can't be force-emailed past an opt-out. - ⚠️ Before this, the admin panel wrote 13 invented category names (
new_redemption,deposit_anomaly, …) that no event answered to and no send path read — the toggles persisted and read back into the switches while every email still went out. Migration 0101 deletes those rows. Do not reintroduce a settings-only taxonomy: a toggle that isn't keyed on a realevent_keycannot be enforced, and its failure mode is silent. - Events in the catalog with no producer are surfaced to the panel as
emitted: falseand rendered disabled ("Not yet active") instead of as live controls. Onlylp_mint_failedis left on the admin side, and it is slated for deletion (v3-103). - An FM row on a pool with no
fund_idcan reach nobody — there is no fund to resolve members from, so the row would sitSENDINGforever with no one to chase it. The worker absorbs this into the existingfm_notification_failedalert (failureType: 'NO_FUND') rather than adding an event, anddedupByEntityis safe here because it keys on the stuck row itself: one alert for this orphan, not one per sweep. It never alerts about the alert. fund_member_changedis written to two perspectives on purpose (v3-103): the email is second-person to the member whose access changed (routed byrecipient_name), while the in-app summary is third-person —{memberName} is now {role} on {fundName}— because anADMIN-type row is a shared ops feed under Model A, not a personal inbox. This is not an inconsistency to clean up.
large_deposit threshold
large_deposit fires from deposits.post.create when a deposit exceeds platform_config.largeDepositThreshold (PUT /admin/settings, admin-web → Platform Configuration). There is no default and no fallback constant: while the setting is empty the alert never fires. "Large" depends on the fund's deal sizes, so an admin states it; the old My Settings panel instead displayed a hardcoded > $100,000 that came from a useState default and corresponded to nothing server-side. Strictly greater than, matching the copy ("has exceeded the configured alert threshold"). ADMIN-only — the investor and the FM already get deposit_confirmed / deposit_confirmed_ops for the same deposit — and individual admins can still turn the email off in My Settings.
Schema
| Table | Holds |
|---|---|
notification_events | One row per occurrence. event_key, idempotency_key (UNIQUE), batch variables, subject_type/_id |
notifications | One row per (event, person). recipient_kind/_id, per-recipient variables, rendered in_app, category, read_at. No status — an inbox item exists or it does not |
notification_deliveries | One row per (notification, channel). destination, status, attempts, next_attempt_at, provider_message_id, failure_type |
email_suppressions | Addresses SES reported as a hard bounce or complaint |
notification_preferences | Unchanged. (user_type, user_id, category) where category is the event key |
dashboard_alert_counts.failed_notifications | excludes failure_type = 'INVALID_RECIPIENT' (migration 0107) |
pool_follows (PK user_id,pool_id) | the opt-in list behind pool_lifecycle_active (migration 0109) |
users.email_verified_at · pending_email · email_verification_token · email_verification_expires_at | investor email verification (migration 0033) |
Full column list in 11-db-schema. Migration 0110 creates these and drops notification_logs, notification_status and recipient_type; old rows are not migrated (pre-launch operational history, and keeping a parallel legacy table would preserve exactly the ambiguity the split removes).
Delivery states
delivery_status ∈ PENDING | SENT | BOUNCED | COMPLAINED | FAILED | SKIPPED.
SKIPPED is not a failure — no attempt was made — and BOUNCED is actioned by the suppression list rather than by an operator, so the admin failed-notifications KPI counts only FAILED. That replaces the migration (0107) that had to except INVALID_RECIPIENT from the old count.
API compatibility
The read endpoints deliberately return the shapes they always returned, rebuilt from the new tables (notifications/feed.ts, notifications/delivery-log.ts) — a storage change should not become a client change. Two consequences:
- A feed row reports
status: 'DELIVERED', which is what the value meant to a reader of the feed ("this notification is yours"). The real outcome lives on the delivery. - The ops log reports the real delivery status, untranslated. Collapsing bounce and complaint into
FAILEDwas the first attempt and was wrong: the ops UI gates its Resend button on the status, and onlyFAILEDis resendable, so a bounce shown asFAILEDwould have offered a retry the API refuses. TheNotificationLogwidget takes its status vocabulary as props, so the six values are injected rather than mapped down.
Status
Deployed and verified. dev runs 0110 as of 2026-07-31; infra tsc clean · 196/196 · copy, producer and units guards clean. The bounce path was exercised 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 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 (v3-108).
Open — code
redemption_fundedandlp_mint_failed(pluslp_minted) are authored but have no producer. They are declared incatalog/index.tsUNEMITTED, which the load-time assert and the producer guard both check, so they cannot be silently half-live — but the sheet already dropped two of them and the entries should follow.epoch-executestill selects aMATUREDpool into its candidate set (it filtersepoch_duration_days > 0+pool_address+deleted_at, but notlifecycle_status) — a benign state alerting as a failure. Whether that path should skip rather than alert is undecided.LEAD_DAYS3 → 7 onepoch_funding_date_due.- Four events interpolate
{poolId}indeepLinkwithout declaring it invariables(fm_shortfall,yield_distribution_due,yield_distribution_overdue,yield_distribution_escalation). Producers do pass it, so no CTA is broken; the declaration is what is missing, and the catalog test that finds it is currently the only reader of that fact.
Open — copy / product
- The sheet is behind the code on 12 events. SoT is the code (v3-103), so this is a sync into the sheet — most consequentially
redemption_rejected, whose sheet row still carries{reason}in its detail rows: that is the operator's internal memo, and rendering it is both a PII leak and a tipping-off risk. The durable fix is to generate the sheet from the catalog rather than hand-editing both. - No sheet rows yet for
redemption_exit_gate_blockedorepoch_funding_date_due(both live in code). epoch_demand_finalizedandover_funding_detected— v3-103 recorded these as unblocked by the epoch merge, with v3-100 settling the over-funding behaviour; the rebuild entry (v3-108) still describes them as blocked on the demand snapshot. Reconcile before building either — the disagreement is about whether a payout total is confirmed at notice time, which is the whole reason the events were parked. ⚠️over_funding_detectedhas since been built regardless — catalog entry plus a producer inlib/money/usecases/epoch.tsthat fires after settlement — and its body states the carry-forward behaviour backwards; see the copy defect above.- Em-dash: the no-em-dash rule applies here too (v3-103). Replacement is a spaced hyphen. The sheet is already converted; the code sweep is outstanding and now covers
catalog/rather thancopy.ts— 102 em-dashes remain across the eight catalog files (pool.ts25,redemption.ts34 are the bulk), so this is a real pass, not a touch-up. - Four sheet-vs-code recipient divergences were found during the rebuild and deliberately left alone (
fm_shortfall,holdback_release_deferred— since removed with the hold-back in 0183 —yield_distribution_escalation,nav_change_*_ops): changing who receives an email during a structural refactor is the wrong risk, and one would have removed admin visibility of a reserve shortfall. - Whether a complaint may ever be un-suppressed is undecided. The management endpoint allows it today; a complaint is a withdrawal of consent that mailbox providers act on, so reversing one on a support request may not be ours to do.
redemption_rejected/redemption_returned_unfundedstrings are pending legal sign-off (Notion "Legal Review Required" #25). Branching, variables andfailure_typerouting are final; only the strings may change.
Deferred, deliberately: operator scoping (both admins and operators sit in the opsTeam audience; each now has their own row and read state, so the shared feed is gone — a separate audience is what remains) · Telegram/Slack · the 1h optional digest · SES DELIVERY events (SENT already records acceptance; a delivery event would add a write per email no operator acts on) · the legal items under email verification.