Operations & Compliance
Notification operating rules, audit log retention, yield overdue tracking, and TVL cap enforcement.
Notification rules
📖 SoT: Notification System. The event inventory, recipients, FM isolation, copy registry, preferences and the email/in-app delivery model all live there. This page keeps only the operating rules, so there is one place to change when an event is added.
The per-event matrix that used to sit here is gone, not moved: it was a 13-row snapshot of a registry that now holds 66 keys, and it kept being read as the inventory.
- Channels (V1): email + in-app. In-app is an always-on baseline — every notification lands in the feed, and it is not a per-event toggle. Email is transactional and EN-only, from
no-reply@aset.finance. Telegram / Slack are V2. - Critical is always sent and cannot be disabled (D4): material events, the redemption flow, and account safety. Optional events respect
notification_preferences, now enforced for all three audiences — admin/FM since 2026-07-29 (migrations 0100/0101), investor since 2026-07-30 (migration 0108). - A preference is keyed on the copy registry's
event_key, so the send-time gate comparesnotification_preferences.categoryagainstnotification_events.event_keywith no mapping layer. A toggle naming anything else cannot be enforced and fails silently — which is exactly what both settings panels used to do. - Retry: SQS redelivery with 1/5/15/60/240-minute backoff, then the DLQ; on terminal failure the worker raises
fm_notification_failedto admins. Benign outcomes are deliberately kept out of that queue: an opt-out, a missing address and a suppressed address are each recorded as aSKIPPEDdelivery with its reason (OPTED_OUT/NO_ADDRESS/SUPPRESSED_ADDRESS) rather than a failure, andINVALID_RECIPIENTis excluded from the failed-notification KPI (0107). The KPI countsFAILEDonly. An ops queue that never reaches zero stops being read.
Data model — three tables since 0110
The single notification_logs row that did both jobs was dropped in migration 0110 (v3-103). The model is now:
| Table | Holds |
|---|---|
notification_events | one row per thing that happened, keyed by event_key |
notifications | one row per recipient per event (the in-app inbox item) |
notification_deliveries | one row per channel attempt, status in delivery_status |
So there is no SENDING status any more and no notification_logs to query — delivery_status is PENDING / SENT / BOUNCED / COMPLAINED / FAILED / SKIPPED. See 11-db-schema → Notifications.
Audit Log Retention & Export
Audit feed = two streams, joined at read time (v3-41) — re-scoped by v3-86
The audit feed merges two sources at query time. v3-86 pins the log's identity as an audit record (SoT), with the operational Activity feed a read-view layered on top, and re-scopes what each stream writes.
- On-chain / economic events → READ from the indexer tables. The
onchain-indexer.schedulermirrors every contract event (Deposited,Redemption*,Yield*,Nav*,EmergencyFrozen/Unfrozen, LPTransfer) into dedicated tables (deposits,redemption_requests,yield_distributions,nav_history,redemption_epochs,portfolio_positions) withtx_hash/log_indexprovenance. The audit API surfaces these — it never re-INSERTs them intoactivity_events(no double-write / second SoT). actor = investor (viausersjoin) or system/wallet. - Human privileged actions → WRITTEN to
activity_eventsat the admin-web→API boundary. Scope = discretionary acts touching money / permissions / investor state / pool-fund state / sensitive data. Wired today:FREEZE/UNFREEZE/PAUSE,IMPAIR_*/WINDDOWN_*,LIFECYCLE, governance,NAV_*,REDEMPTION_APPROVE/REJECT/HOLD/RELEASE/NOTIFY/ESCALATE,FEE_WITHDRAW,PII_ACCESS, andPOOL_ARCHIVE/POOL_RESTORE(v3-110 —pools.delete.tsandpools.patch.update.tsrespectively; archive/restore is the one pool mutation pair already writing an audit line). Added by v3-86 (writers to build): pool/tranche create·deploy·publish·update, fund CRUD + fund-member, admin-user CRUD + permission + admin-wallet bind/verify, investor qualification/tier.
Actor & record model (v3-86)
- Actor = hybrid resolution. The human write-stream snapshots
actor_name+actor_roleat write time (join is fallback only) so a record survives renames / account deletion; investor economic events resolve via ausersjoin; scheduler/indexer = "System". Display =name · role-badge; on-chain signer = System +tx_hash. - Standard record: When · Who(snapshot) · What · Target(label snapshot) · Change (before→after) · Why(
reason) · Proof(tx_hash) · Severity · Outcome.reasonis required on high-risk acts only (impair · wind-down · freeze · permission · qualification). Failed attempts are logged withoutcome=failure. - UI = Audit / Activity two tabs. Audit = human write-stream (default); Activity = economic + on-chain full feed. Filters: date · actor search · category · outcome · severity. FM sees own-fund Activity only (no internal audit / permission / PII).
| Setting | Value |
|---|---|
| Retention period | 5 years — SG MAS Notice PSN02 (DPT records ≥5y) / TH AMLA §22 (≥5y, first 2y readily available). MY 6–7y if MY entity applies (legal to confirm). |
| Immutability | Append-only, DB-enforced (v3-86 / migration 0082) — a BEFORE UPDATE/DELETE trigger blocks all mutation of activity_events for every role (incl. the Lambda service key); DELETE is allowed only inside archive_expired_activity_events(). Hash-chain tamper-evidence deferred. |
| Archive | 5y+ records moved to the activity_events_archive cold table (v3-86 / 0082) via archive_expired_activity_events() (atomic INSERT+DELETE) — records are relocated out of the hot table + audit_feed, never destroyed. Replaces the earlier hard-purge job. (S3 cold storage is a later enhancement.) |
| Export format | CSV. Admin-only (Operators cannot export). Reflects the active filter set. |
| Dashboard default | Last 48 hours. "View All" opens full log with date range filters. |
| PII in export | Wallet addresses included (pseudonymous). Emails NOT included. |
| Source | activity_events (human actions, SoT) + on-chain indexer tables (read-view UNION). See decision v3-41, re-scoped by v3-86. |
Web Response Security Headers
Both CloudFront SPA distributions enforce the same response-header policy: HSTS, X-Content-Type-Options, X-Frame-Options: DENY, strict-origin referrer policy, a restrictive Permissions Policy, and Content Security Policy. The CSP blocks external scripts, objects and embedding; permits TLS/WSS connections needed by the API, wallets, OAuth and KYC; and currently allows inline scripts/styles because React Router's static SPA output emits inline bootstrap code. Tightening script-src to hashes/nonces requires a build-time hash pipeline.
Yield Overdue Tracking
Yield Overdue Tracking
Calculation: next_yield_due = last_paid_period_end + yield_frequency. The anchor is the END of the period the last distribution covered, not the moment it was settled, so the grid stays fixed to the offering close (v3-145) and a late distribution costs nothing but its own lateness.
🔴 Except after maturity on a repayment pool (v3-147). There the coupon rides the cycle: the next date is the earliest cycle payout date (redemption_epochs.funding_date) after the anchor, because the yield payout and the principal repayment are agreed to land on the same day and a cycle's date does not move when funding is late. The cadence would drift off it on the first late payment. The daily sweep also publishes that date on chain (setYieldDueDate), which the contract otherwise advances by a flat 28-day step. period_start / period_end are derived by POST /yield-distributions when the caller does not supply them (neither recording path does), with the final period truncated at maturity.
Trigger: If now > next_yield_due and no distribution recorded → set yield_overdue = true
Action: Alert Admin + Investors. Overdue badge shown on pool detail and admin yield dashboard.
Scope: every published lifecycle except UPCOMING (no holders to owe). This was ACTIVE-only, which contradicted the sweep's own recompute pass: that pass has always raised yield_overdue on IMPAIRED and WIND_DOWN pools and nobody was ever notified. CLOSED and MATURED were excluded even earlier, at the schedule itself, so the last unpaid period of a fixed-term pool lost its date, its queue row and its escalation on the day the pool matured.
Note — an overdue period is not a row. yield_distributions gets a row only when a gross is recorded, so the admin Yield "Pending" tab shows due periods, not records. Since v3-104 (2026-08-03) those come from the read model: GET /yield-distributions?include_due=true synthesizes one row per pool from next_yield_due, carrying a server-computed estimated gross/net (from the same computeYieldFees a real settlement uses) and missed_periods — the sweep never rolls a missed date forward, so a months-late pool is one row that states how many cadence periods it owes. The old browser-side join of GET /pools against ?status=PENDING, and the front end's own APY formula and copy of the fee split, are gone.
Note — the due queue is no longer overdue-only. Since v3-120 (2026-08-07) the window reaches 7 days ahead of the due date (next_yield_due < now + 7d), so a period is actionable for a week before it is late. Funding a distribution is not a same-day action, and the previous behaviour meant the first place it appeared was the day it was already overdue.
⚠️ A row in this queue is therefore not necessarily late. Each carries is_overdue, and every "money holders are already owed" figure filters on it — the admin exposure card, the By-Fund owed totals, the overdue pool counts. overdue_days cannot substitute: it is clamped at 0, so an approaching period and one due today both report 0. Summing the whole queue into a figure labelled overdue inflates it, and nothing fails.
This window sits before the notification pipeline rather than overlapping it: pools.scheduler.yield-due waits YIELD_DUE_GRACE_DAYS (3) past due before escalating and re-sends every 7 days, so a period is visible in the queue for a week, goes overdue, and only then starts the escalation clock.
Note — a stalled distribution is the state to watch now. With the legacy server-key create path removed (v3-104), the failure mode that needs a human is a PROCESSING row older than 24h: the FM's depositYield landed on-chain, so the yield is sitting in the pool, but nobody ran POST /{id}/distribute and holders have not been paid. Recovery already exists (that endpoint, plus the row action on the admin Yield table). Surfaced as dashboard_alert_counts.stalled_yield → the dashboard's "Stalled Distributions" alert, and under the Yield screen's Failed tab as Stalled.
✅ The push is built (2026-08-04): yield.scheduler.stalled.ts emits yield_distribution_stalled with a daily-bucket idempotency key, so it re-sends once a day while the row stays stalled. Audience is opsTeam only, deliberately — recovery is ORACLE_ROLE-gated on Aset's key, so an FM cannot act on it. (This note previously read "not built — blocked on copy". It shipped with both in-app and email copy; the claim was stale.)
Note — the batch run can produce this state on purpose. Since v3-123 (2026-08-12) the Yield review can sign every priced due pool in one pass, as N separate depositYield signatures. When a deposit confirms but its follow-up settle fails, the run marks that item done rather than failed and says so on the row: done is what stops a retry from depositing the same yield twice, and the deposit is irreversible. The consequence for ops is that a successful-looking batch run can still leave stalled rows behind, which is exactly what the 24h sweep above is for — the run's own message points the operator at it.
TVL Cap Enforcement
TVL Cap Enforcement
Derived (no flag): "Fully Subscribed" is computed from tvl >= capacity on pools. (The former investment_blocked flag was dropped — migration 0018, 08 v3-29.)
- On each successful LP mint →
tvlis recalculated:tvl = tvl + deposit_amount - On new deposit submission → check
tvl < capacityFIRST. Iftvl >= capacity→ reject immediately, show "Pool is fully subscribed" - The pool reopens automatically once redemptions bring
tvlback belowcapacity— no manual toggle needed (manual deposit halt is the separateis_pausedflag)
⚠️ Edge cases
- Deposits already PENDING when cap hit: continue processing (accepted before
tvlcrossedcapacity) - PENDING deposit's LP mint pushes TVL over: still processes (
tvl < capacityheld at submission time) - The cap check is a forward-looking gate, not retroactive
SBT Mint Queue & DLQ Alarm
SBT on-chain op queue
Queue: aset-sbt-mint-<stage>.fifo — carries every on-chain SBT op (mint / burn / revoke). Single FIFO message group serializes them behind the one platform signer wallet (no nonce collisions).
Producers: the KYC review worker (GREEN → mint, RED → burn/revoke), the reconcile sweep (re-queue stuck mints), and POST /kyc/mint-sbt (manual, returns 202).
Consumer: kyc.worker.mint-sbt — reportBatchItemFailures, visibility timeout 180s, function timeout 120s.
Retry → DLQ: maxReceiveCount = 5, then aset-sbt-mint-dlq-<stage>.fifo with 14-day retention (SQS maximum).
DLQ alarm runbook
A message in the DLQ means an SBT op exhausted all 5 retries — auto-recovery has given up.
Alarm: CloudWatch aset-sbt-mint-dlq-<stage> fires when DLQ ApproximateNumberOfMessagesVisible ≥ 1 (1-min period) → SNS topic aset-sbt-mint-alarm-<stage> → email. The subscriber must confirm the SNS subscription once (AWS double opt-in) before mail is delivered.
Investigate: signer wallet balance/gas, chain config (isSupportedChain), contract pause/permissions. After the root cause is fixed, redrive DLQ messages back to the main queue (or let the 5-min reconcile sweep re-queue affected APPROVED + NOT_MINTED/FAILED users).