Skip to content

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 compares notification_preferences.category against notification_events.event_key with 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_failed to admins. Benign outcomes are deliberately kept out of that queue: an opt-out, a missing address and a suppressed address are each recorded as a SKIPPED delivery with its reason (OPTED_OUT / NO_ADDRESS / SUPPRESSED_ADDRESS) rather than a failure, and INVALID_RECIPIENT is excluded from the failed-notification KPI (0107). The KPI counts FAILED only. 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:

TableHolds
notification_eventsone row per thing that happened, keyed by event_key
notificationsone row per recipient per event (the in-app inbox item)
notification_deliveriesone 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.

  1. On-chain / economic events → READ from the indexer tables. The onchain-indexer.scheduler mirrors every contract event (Deposited, Redemption*, Yield*, Nav*, EmergencyFrozen/Unfrozen, LP Transfer) into dedicated tables (deposits, redemption_requests, yield_distributions, nav_history, redemption_epochs, portfolio_positions) with tx_hash/log_index provenance. The audit API surfaces these — it never re-INSERTs them into activity_events (no double-write / second SoT). actor = investor (via users join) or system/wallet.
  2. Human privileged actions → WRITTEN to activity_events at 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, and POOL_ARCHIVE / POOL_RESTORE (v3-110pools.delete.ts and pools.patch.update.ts respectively; 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_role at write time (join is fallback only) so a record survives renames / account deletion; investor economic events resolve via a users join; 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. reason is required on high-risk acts only (impair · wind-down · freeze · permission · qualification). Failed attempts are logged with outcome=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).
SettingValue
Retention period5 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).
ImmutabilityAppend-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.
Archive5y+ 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 formatCSV. Admin-only (Operators cannot export). Reflects the active filter set.
Dashboard defaultLast 48 hours. "View All" opens full log with date range filters.
PII in exportWallet addresses included (pseudonymous). Emails NOT included.
Sourceactivity_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.)

  1. On each successful LP mint → tvl is recalculated: tvl = tvl + deposit_amount
  2. On new deposit submission → check tvl < capacity FIRST. If tvl >= capacity → reject immediately, show "Pool is fully subscribed"
  3. The pool reopens automatically once redemptions bring tvl back below capacity — no manual toggle needed (manual deposit halt is the separate is_paused flag)

⚠️ Edge cases

  • Deposits already PENDING when cap hit: continue processing (accepted before tvl crossed capacity)
  • PENDING deposit's LP mint pushes TVL over: still processes (tvl < capacity held 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-sbtreportBatchItemFailures, 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).