Portfolio data model: ledger-first rows, one derivation worker, values computed at read, venue adapter contract (2026-09-24)
Decision record and implementation plan for the portfolio data-model overhaul Fred settled on 2026-09-24 (issue #917, corrected). Everything under "Rulings" is decided; do not re-litigate it in a PR. Everything under "Sub-PRs" is the instruction set for the implementers and reviewers. Ships as ONE final PR feat/portfolio-ledger-first -> staging, assembled from sub-PRs merged into that integration branch, followed the same day by the release PR staging -> main. Main's merge commit is the one revert point.
Why now. Prod has no real users (one account, Fred's own). A restatement of served numbers costs nothing today; after the first outside cohort every change to what a return says is a communication. The 30-day history window bounds every rewrite this plan needs, so nothing gets cheaper by waiting except the user count.
Process rules (Fred, 2026-09-24). Every agent in this programme — implementer, reviewer, re-reviewer, final gate — is Opus 5.5. Browser QA, the e2e suite and the staging soak are SKIPPED for this programme. What is NOT skipped: the scenario suites (they are the specification), the byte-identical gate on wave 1 (§6), one comparator run on real wallets for wave 2 (§7), typecheck + unit tests + docs build green on the integration PR, one manual screenshot pass at the end (stamp, as-of day, All view, activity statement). Findings go on the PR as comments; a GitHub issue is opened only with Fred's per-issue approval (AGENTS.md).
1. Rulings (settled, not to be reopened)
R1 Three records, three roles. The ledger (portfolio_flow_events_v2, block-precise movement records derived from raw_events) decides which positions exist, since when, and what moved. Readings (portfolio_position_snapshots, a balance read from chain at a block) decide the quantity at that moment when they are newer than the last movement, supply the opening balance at the window start, and AUDIT the ledger. The price and rate series (unchanged: hourly token_price_bars, 6h share rates, block-pinned PT factors and fund redemption rates, CoinGecko/DefiLlama live tip) decide what a quantity is worth. Nothing about where prices come from, the pricing categories (market-priced / redemption-priced), the registries or the coverage rule changes in this programme.
R2 A movement alone creates or updates a row. A row exists at moment T when the ledger's occupancy says the leg is held at T. Its quantity is the newer by block of (the last reading at or before T, the last movement record at or before T, which carries to_balance). Today a row needs BOTH records to agree (buildV2Positions intersects the ledger's verdict with the newest reading); that intersection is removed.
R3 Readings are the audit. After every stored reading (6h checkpoint or Synchronize), for every leg the reading covers, the ledger-derived balance at the reading's block (opening + movements, exact integer arithmetic) is compared with the read quantity. A disagreement is a BREAK. A break is first run through the automatic explanation (R6); what remains is booked as a correction (R5) at the reading's block and alerted (R8). The ledger is never left disagreeing with a reading that succeeded. The existing cross-checks (compositionDrifted, the disappearing-position alarm, the ghost adjudicator, the 6h acceptance reconciler) are kept and extended to the new kinds, never weakened.
R4 Two new movement kinds, policed like the twelve. opening — the balance a leg holds at the first block the ledger speaks for (the history floor, or a leg's first coverage); written from a reading, to_balance = qty_delta = the read quantity, one per leg per window start. adjustment — an unexplained difference between the ledger and a successful reading, signed, to_balance = the read quantity. Both are in the flow set of BOTH published lines (F_accrual and F_total_return) so neither is ever booked as earned. Both are added to every copy that enumerates kinds: the pfe2_kind_chk CHECK (migration), v2/classify.ts (no default; a new kind is a compile error until classified), v2/fixtures/reference.ts, v2/fixtures/rows.ts (the CHECK mirror and its test), reconcile/kinds.ts and its test (the contract §6 table gains two rows), v2/activity-actions.ts. An adjustment row carries explain_status (unexplained | accepted), cause (free text, e.g. native-eth-transfer) and reading_block.
R5 What a correction shows (Fred, 2026-09-24). (a) The return over the stretch that ends at the correction's reading reads "not measured" on both lines (the withheld shape, never 0, never a gain or loss). (b) For a leg that has an entry wedge (a yield-bearing asset with both a market and a redemption value), the adjusted quantity enters at the wedge of the reading that found it, exactly as a real top-up at that reading would: market side = newest accepted hourly price at or before the reading's block time (48h walk-back, else withheld), redemption side = the rate read on chain at that block. Par assets have no wedge and no decision. (c) The activity statement shows the correction as its own line — "Balance adjustment +X (asset), cause under review" (unexplained) or "…, source not tracked" (accepted) — never as a deposit, withdrawal or user action. Amended 2026-09-26 (Fred): ACCEPTED corrections are held back by default, folded into one summary line like network fees ("N balance adjustments, sources not tracked", expandable to the individual lines); UNEXPLAINED corrections always show individually. Reason: gas moves native ether without a log at almost every checkpoint of an active wallet, up to four accepted lines a day. opening rows are never shown in the activity statement and never counted as flows in any user-facing total; they are the opening anchor.
R6 Automatic explanation before any correction. For a break on leg L over (lastAgreedBlock, readingBlock]: run the venue adapter's explain (R10) — by default, fetch the leg's own token transfer logs for the wallet over that range (one eth_getLogs) and, for a venue leg, the venue's declared secondary events (position-NFT transfers, share transfers, migration events). A found event becomes an ordinary movement record (a transfer_in/transfer_out or the venue's own kind) and the break closes with no correction. Only an unexplained residue becomes an adjustment. When a decoder is later extended, the wallet is re-derived over the range and the adjustment is replaced by the proper record.
R7 Dust. A read quantity whose value at the reading's price is below $0.01, where the ledger says the leg is empty, is empty: no row, no correction, no alarm. The floating-point back-derivation that manufactured the ghost-row case (quantityBeforeFirstReceipt, toBalance − qtyDelta in doubles) is retired: every balance comparison in the audit is exact (bigint), and pre-first-movement balances come from opening rows, not arithmetic. Clarified 2026-09-26 (PR #963, comparator F1; flagged for Fred): a sub-cent holding at a coverage start is HELD by the ledger (its opening is written) and only hidden on the page (the rows and the All view; a book's own value keeps it), and a holding that has earned or lost a cent or more stays visible.
R8 Alerts. (a) An adjustment with explain_status = unexplained pages (Telegram, the existing run-cron.sh contract for cron paths; the worker's own freshness/alert arm for the continuous path) with wallet, venue, leg, block range and size. (b) A cause marked accepted never pages again (the accepted-cause registry is code, edited by PR; the first entry is native-eth-transfer). (c) A leg that fails to read at two consecutive readings pages as a reader failure. (d) A per-venue budget: a venue whose unexplained corrections were zero for the trailing 7 days pages on the first new one. The 6h acceptance reconciler keeps its identity check and admits the two new kinds.
R9 One derivation worker. One durable queue (portfolio_derive_jobs: wallet, from_block, to_block, priority, state, attempts, claimed_by, timestamps) consumed by ONE long-running process (creddit-ledger-worker, pm2, --kill-timeout 120000, restarted by every deploy like the ingester) under ONE lock discipline. Producers: the ingester's cycle end enqueues (wallet, prevTip+1, newTip) for every wallet in the dirty set of the newly ingested range (continuous derivation, R11); wallet enrolment enqueues the whole window at high priority; Synchronize / page-load refresh enqueues (wallet, cursor+1, ingestedTip) at top priority and waits up to 20 s for it; the 6h tick enqueues the population sweep (cursor+1, min(anchor−64, ingestedTip)) at low priority and advances V2_TICK_CURSOR_SCOPE when the sweep completes. Same provisional/settled rule for every job (#777's settle line travels with the job). The [partial]/[fail] alert contract and the jit-pending semantics survive as job states.
R10 Venue adapter contract. Every venue adapter (aave-family incl. SparkLend, morpho-blue, fluid, pendle, erc4626, wallet/escrow) exposes exactly four obligations: (1) decode — movements with qty_delta, to_balance and the observed counter-value when the transaction states one (today's DeriveAdapter); (2) readBalance(leg, block) (today's reader); (3) declare(leg) — the leg's accounting kind (value | index), quantity unit (assets | shares | scaled), side, book, category, and its structural role (plain | smart-pair member | escrow | derived-evidence); (4) explain(break) (R6), with the shared default. The engine (v2/engine.ts), segments.ts, attribution.ts and the classifier (pnl.ts, assemble.ts's legTaxonomy) consume ONLY declarations: after this programme no venue name appears in src/lib/portfolio/v2/, and a new venue is accepted on its shadow break count (R8), not on review.
R11 Freshness. Movement records are derived within one ingester cycle (~60 s) of the chain for every wallet that had events, and the "Updated" stamp on /portfolio is the block time up to which the wallet's movements have been derived (advancing every cycle, events or not). Readings stay on the 6h grid as the audit cadence; Synchronize stays as "verify now" (a fresh reading + a top-priority derivation job). The per-wallet 5-minute cooldown, the reading-at-the-ledger's- block invariant and the one-live-tip-per-wallet rule from plans/live-sync-step1.md are kept.
R12 Values are computed, never frozen. A reading stores facts (qty_raw, index_raw, block, timestamp); value_market / value_redemption are computed at read time as quantity × the series' price/rate at the reading's moment, with the SAME series and the SAME walk-back rules the writer uses today. Movement values (A_mark) are re-priced from the same series at the movement's block, so ΔV − F never mixes two price regimes. For THIS release the writers keep populating the stored value columns (expand; the old read path stays reproducible for a revert); the contract migration that drops them is the release after. A late price now fills a hole by itself; a corrected bar re-values history by itself; a feed outage can never look like a missing balance. The #798 per-line bridge stays (a still-unpriced moment is still bridged).
R13 Expand only, one revert. No destructive migration in this release. New kinds, new columns and the jobs table land beside today's records. A revert of the release commit restores the old code, which tolerates the new rows: the loader's narrowing predicate counts unknown kinds and the reconciler reports them (already the case), and the old writers ran beside the new ones for the whole release (R9's sweep = today's tick flow pass; today's minutely drain keeps running as a no-op producer of identical rows). Rollback runbook in §9. Corrected 2026-09-26 (PR #960 review B1): only the old loader tolerates the new kinds. The old events list throws on one, the old tick's wallet-token universe guard counts one and refuses the range, and the old reconciler pages on one, so the rollback deletes both kinds right after the revert (§9). The ruling stands: no destructive migration, one revert.
R14 Out of scope. Price sources, pricing categories, the coverage rule, registries, the carries/screener/funds pages, the UI beyond: the stamp semantics (R11), the activity line (R5c), the removal of the per-row-needs-both-records gate (R2). No new per-row "last read" label.
2. Target architecture
chain ──eth_getLogs──▶ ingester (60s cycle, wallet-count independent) ──▶ raw_events
│ cycle end: enqueue dirty wallets
▼
enrolment ─────────▶ portfolio_derive_jobs ◀───── Synchronize / page load (top priority)
6h tick sweep ─────▶ (queue) ◀───── ingester (continuous)
│
▼
creddit-ledger-worker (one process, one lock discipline)
adapters.decode ──▶ portfolio_flow_events_v2 (+ opening/adjustment)
▲
readings (6h checkpoint, Synchronize) ── audit: ledger balance vs read qty
│ break ──▶ adapters.explain ──▶ record | adjustment(+alert)
▼
read path: rows from ledger occupancy; qty = newer(reading, to_balance);
value = qty × series(price|rate at moment); engine ΔV − F unchangedWhat the engine's ten withhold shapes become:
W1 unread-endpoint(row missing at a reading): the row stays (ledger quantity), the interval is bridged as today; a second consecutive failed read pages (R8c).W6 unanchored-leg(birth without quantity): retired for the Fluidno-scaled-companioncase by the adapter reading state at the block (§5 S4); where a quantity is still unknowable the audit's next reading writes theopening/adjustmentthat anchors it.W9 ghost-row: retired (R7).W10 ledger-blind-move: retired; replaced by R6 explanation, elseadjustment+ R5.- The spine-informed
heldAtOpeninsegments.ts: retired; occupancy comes fromopeningrows. W2,W3,W4,W5,W7,W8are about pricing, seizure algebra, smart-pair composition, anchors, certification and receipt ambiguity — NOT two-record seams. They stay as they are; W2 loses most of its population once values are computed (R12).
3. Data model (expand only)
Migration 114-ledger-first-kinds.sql — on portfolio_flow_events_v2: widen pfe2_kind_chk to include 'opening' and 'adjustment'; add explain_status text (CHECK in ('unexplained','accepted'), NULL for every other kind, NOT NULL for adjustment), cause text, reading_block bigint; CHECK: kind = 'opening' ⇒ qty_delta = to_balance; kind IN ('opening','adjustment') ⇒ reading_block IS NOT NULL. Partition-aware (the table is monthly-partitioned; ALTER on the parent, NOT VALID + VALIDATE if the CHECK rewrite would lock).
Migration 115-derive-jobs.sql — onchain_credit.portfolio_derive_jobs (id bigserial, chain_id int, wallet text, from_block bigint, to_block bigint, priority smallint, kind text CHECK IN ('sweep','enrol','sync','continuous','rederive'), state text CHECK IN ('queued','running','done','partial','failed'), attempts int, settle_line bigint, claimed_by text, claimed_at, finished_at, error text, created_at), indexes on (state, priority, created_at) and (wallet, state). Owned by the onchain_credit role.
Readings: the value columns stay populated this release (R12 expand); the follow-up release drops value_market/value_redemption (DESTRUCTIVE, hand-run).
Migration 116-observed-rate-facts.sql (decision b2, 2026-09-25, from PR #950's finding F2–F4/F8: the writers read a wrapper's redemption rate, a fund's NAV and a share-counted leg's per-share rate ON CHAIN at the reading's block; the 6h series is only their fallback and funds and share legs have no series at all, so a read-time valuation cannot reproduce the redemption line from the series). The rate a writer observes at the block is a FACT and is stored as one: nullable columns on portfolio_position_snapshots — rate_raw numeric (the block-pinned redemption rate exactly as read: share rate, fund NAV per share, per-share rate), rate_source text (chain | series | live) — and the same facts in a receipt's meta beside the PT facts #940 already stamps (no receipt migration). The PT pre-window fills table gets the fill's consideration stored (expand, decision f1). Writers populate them from this release; the read computes the redemption line from the stored rate when present and from the series only where the writer itself used the series (rate_source = series). The market line always comes from the bars. Decision c1: the live-tip writer stops storing a live price level where no bar exists (the tip served at page load still uses the live fetch; the stored row is mirror-valued, M23).
Cursors: portfolio:derive:v2:<wallet> and V2_TICK_CURSOR_SCOPE keep their meaning; the worker is the only writer of both. jit-pending markers become sync jobs.
4. Waves and sub-PRs
Wave 1 runs its three sub-PRs in parallel, each in its own worktree off the integration branch, each byte-identical under §6. Wave 2 is one sub-PR on top of the merged wave 1 (S5 is a second agent pair inside it). Wave 3 is the comparator, docs and release.
| Wave | Sub-PR | Gate |
|---|---|---|
| 1 | S0 golden harness (prerequisite, small) | commits the golden files on the base commit |
| 1 | S1 venue adapter contract | byte-identical (§6) |
| 1 | S2 values computed at read | byte-identical (§6) on the fixture DB; §7 numbers equal on prod-like data |
| 1 | S3 one derivation worker + continuous derivation | byte-identical rows (§6.3); worker soak on the fixture DB |
| 2 | S4 ledger-first rows, opening/adjustment kinds, reading audit, R5/R7 | approved-diff (§7) |
| 2 | S5 automatic explanation, accepted causes, alert budgets | unit + scenario |
| 3 | S6 comparator run on ~12 real wallets | zero unexplained diffs |
| 3 | S7 docs, runbook, fixture seed, deploy + crontab, reset script | docs build green |
5. Sub-PR instruction sets
S0 — Golden harness (scripts/ops/golden-portfolio.ts, tests/golden/)
Build the fixture DB (scripts/fixture/build.sh --quiet), then for every wallet in the fixture seed (every distinct tracked address in scripts/fixture/seed.sql) and for the account views the fixture exercises, call the served builders directly (not HTTP): summary, positions (each view incl. All), history (each range), activity, at a FIXED nowSec and at two fixed asOf days. Serialise deterministically (sorted keys, numbers as written by the wire) into tests/golden/<wallet>/<surface>.json. A test scripts/ops/golden-portfolio.test.ts regenerates and diffs. Commit the goldens generated on the integration branch's base commit. The harness must not touch the network: every live-tip fetcher is stubbed to the fixture's stored values (the existing setDbShareRateReaderForTests / live-level test seams).
S1 — Venue adapter contract
- New module
src/lib/portfolio/adapters/withVenueAdapter = { name, decode (today's DeriveAdapter), readBalance (today's reader), declare(leg): LegDeclaration, explain(break) }and one registryadapters.tslisting the six.LegDeclaration = { accounting: 'value'|'index', unit: 'assets'|'shares'|'scaled', side: 'asset'|'debt', book, category, role: 'plain'|'smart-pair-member'|'escrow'|'derived-evidence', pairKey? }. - Move every venue-specific rule out of
v2/engine.ts(20 Fluid mentions, 4 Aave, 4 Morpho, 3 Pendle, 4 escrow),v2/segments.ts,v2/attribution.tsandpnl.tsinto declarations the adapters produce; the consumers branch ondeclaration.*only. A boundary test (v2/boundary.test.tspattern) fails the build ifsrc/lib/portfolio/v2/contains any of the stringsfluid,aave,spark,morpho,pendle,erc4626outside a comment. explainships with the shared default (R6) in this PR; S5 wires it to the audit.- Byte-identical: §6.1 + §6.2 unchanged. No served number moves.
S2 — Values computed at read
Amended 2026-09-25 after PR #950 round 3 (decisions A + b2 + c1 + f1 + d1 taken; E runs in parallel as the S6 preparation). The first attempt (#950, head beb0eeb4) built the read-side valuation and found two facts the original text missed: (F1) the fixture's stored values are synthetic, so no series reproduces them, and (F2–F4, F8) the writers price the redemption line from rates read on chain at the block, which the 6h series cannot reproduce. Both are resolved by the amendments below; the rest of the original instruction stands.
- A. Restate the fixture FIRST, on the unrefactored code, as the branch's first commits (the S1 pattern): re-price every stored value in
scripts/fixture/seed.sqlfrom the fixture's own bars and rates (extend the series so every asset has bars from its first reading; one price per asset per instant across wallets; makeqty_rawconsistent withqty_underlying× index, since S4's audit compares against it), and add the shapes the fixture lacks (S17): stamped PT fills, a stamped mint and a market buy, tick rows withblock_ts, a live tip, a rejected bar, a cross-book consideration, ERC-4626 and escrow readings, listed-fund and share-counted stETH/eETH readings with their observed rates. Regenerate the goldens on that commit. The S2 code commits come after; the gate (§6.1 + §6.2 + the parity test) is measured against those goldens. - b2. Observed rates are facts (migration 116, §3): the writers (
snapshot-write.tscallers: the 6h checkpoint inscripts/refreshers/portfolio.ts, the live-tip reading inlive.ts, the enrolment replay inbackfill.ts; the receipt marks inderive/marks.ts) store the block-pinned rate they read (rate_raw,rate_source; receipts inmeta), for wrapper share rates, the composed market line's rate, per-share rates of share-counted legs and fund NAVs. The read computes the redemption line from the stored rate when present, from the series only where the writer used the series. The market line comes from the bars (48h walk-back as the writer). - c1. The live-tip writer stops storing a live level where no bar exists. f1. PT pre-window fills store their consideration and are computed at read like an in-window receipt.
ledger-v2-loader.ts:SPINE_ROWS_SQLkeeps selecting the stored values for this release;LedgerV2SpineRow.valueMarket/valueRedemptionand receipts'A_mark(and a PT receipt's mark, #950 B6) are COMPUTED throughvaluation-sources.ts/derive/marks.ts's resolver, read-side, with the writer's exact branches (theread-values.tsdesign from #950 is kept).- A parity test: for every fixture reading and receipt, computed value == stored value to the wire's precision; a mismatch is a finding in the WRITER's pricing, listed on the PR, never silently absorbed. Plus a census tool (
scripts/ops/value-parity.ts, from #950) that classes every differing value on any database read-only, telling late/corrected series data apart from residue. v2/all-value.ts,positionsInputs,loadUsdQuotes, the as-of path, the history builder, the events list, the activity statement, both sides of the acceptance reconciler and the PT exit-cost route read the computed values.- Series reads bounded by the hours priced, not the wallet's age; reads in flight awaited; memo keyed on the registry digest (#950 S13–S15).
S3 — One derivation worker
- Migration 115.
scripts/worker/ledger-worker.ts+run-worker.sh(mirror of the ingester'srun-ingester.sh,.env.localsourced, graceful stop,--kill-timeout 120000). - Producers: ingester cycle end (
scripts/ingester/ingest-events.ts) enqueuescontinuousjobs for the dirty set of the ingested range (reusedirty-set.ts's candidate rule); enrolment (drain-portfolio-backfills.ts/backfill.ts) enqueuesenrol;live.tsenqueuessyncand awaits completion up to 20 s (falls back to today's behaviour: served from the last derived state,refreshed:falsewith reasonderivation-pending); the 6h tick enqueues thesweepand advances the tick cursor when every job of the sweep isdone(partialkeeps the cursor). - Consumer: claims by
(priority, created_at)underSELECT … FOR UPDATE SKIP LOCKED, runspersistV2WithPoolfor the job's range (the ONLY caller after this PR besides the offline history builder), one xact-scoped write lock per job,attemptsbudget 3 thenfailed+ alert line. The three old call sites keep callingpersistV2WithPooldirectly for THIS release (R13); aWORKER_OWNS_DERIVATIONconstant flips them to enqueue-only in the release after, and its test pins both branches. - Alerting:
scripts/check-ingester-freshness.tsgains aderive-lagarm (oldestqueuedjob age,failedcount, worker heartbeat row inchain_scan_cursorsscopeportfolio:worker:heartbeat). - Byte-identical rows (§6.3): a job over a range produces exactly the rows today's tick produces over the same range (the writer is idempotent; assert the tally, not just the rows).
S4 — Ledger-first rows
- Migration 114. The two kinds through every copy (R4).
openingrows written by: the enrolment replay atgrid[0]for every leg the floor read holds (readReplayGridPoint); the audit when a reading finds a leg the ledger has no history for (a coverage start).adjustmentrows written by the audit (R3) afterexplain(R6) fails. - Occupancy (
segments.ts): from receipts only;heldAtOpenparameter deleted.quantityBeforeFirstReceiptdeleted.rebuiltInclusion/buildV2Positions: the row set is the ledger's occupancy at the moment; the row's quantity per R2;latestSpineAtAsOfbecomes "newest reading at or before the moment" and is no longer a gate. - The audit:
src/lib/portfolio/audit/reading-audit.ts(pure core + one I/O wrapper) invoked bysnapshot-write.ts's two callers after the reading commits: per leg, ledger balance at the reading's block (bigint) vsqty_raw; R7 dust; R6 explain; R5 correction; R8 alert lines. - Engine:
W9/W10shapes removed fromWITHHOLD_SHAPES(a shape is a key; removing it must be reflected in every tally, budget and report that groups by shape, incl. the reconciler's suppression report);W1keeps its bridge;W6narrowed per §2. The "not measured" stretch of R5a is filed as an interval-scoped withhold on both lines with a new shapeW11 corrected-reading(scopeleg-interval, magnitude = the correction's value, alarm-free, counted). - Entered basis (
v2/entry-basis.ts,currentSpanBasis): anadjustmenton a wedge-bearing leg blends in at the reading's two marks like a top-up;openingsets the span's opening basis. - Activity (
activity-actions.ts,ActivityFeed):adjustmentline per R5c;openinghidden. - As-of path and All view: row set from occupancy at the requested day's last reading; All value sums computed values (S2).
- Scenario suites: every suite that pinned
W9/W10/heldAtOpenbehaviour is restated in outcome terms under the rulings, with the diff listed cell by cell in the PR body (this list IS the approved-diff ledger §7 reads).
Amended 2026-09-25 (after wave 1 landed: S1 #951, S3 #949, S6 #954, S2a #955, S2b #957, S5-lib #956). S4 ships as two sub-PRs, sequential:
- S4a — data model, occupancy, audit, engine (approved-diff): migration 114 and the two kinds through every copy;
openingrows; occupancy from receipts only; the row set and quantity rule (R2); the reading audit;W9/W10retired,W11 corrected-readingfiled;W6narrowed by the Fluid adapter reading state at the birth block; R7 dust; the scenario restatements with the cell-by-cell diff list. The audit is a worker job (kind = 'audit', one per stored reading, enqueued bysnapshot-write.ts's callers after the reading commits): it runs only once the wallet's derived-through block (settled + provisional) has reached the reading's block, else it re-queues itself without spending an attempt; it compares opening + movements (bigint) withqty_raw, applies R7, runs the S5 library'sexplain(candidate evidence → re-derive the range through the same worker → re-audit), books the residue asadjustment(R5) withexplain_statusfrom the accepted-cause registry, and emits the R8 lines throughbudgets.ts(auditPageLinereturns{exitCode, line}; the reading history handed toreaderFailurePagesis ordered by recording time and the arm's cursor is passed, S5 review SF-2). The 6h tick's readings therefore audit within a minute of the tick, the live tip's within its own sync job. - S4b — surfaces and ops (approved-diff): the activity line (R5c) and
openinghidden; entered basis blending; the as-of path and the All view from occupancy; the "Updated" stamp = the wallet's derived-through block time (R11) on the summary wire and the page; Synchronize = reading + top-prioritysyncjob + its audit;scripts/ops/backfill-opening-rows.ts(oneopeningrow per held leg from each enrolled wallet's floor reading, idempotent, dry-run by default: runs on staging before the NEW capture and on prod at release, §9/§10); docs for both halves;docs/ops/release-steps.mdentry (114 before deploy; the opening backfill; the audit job's first run).
S5 — Automatic explanation, accepted causes, alert budgets
explaindefault implementation:eth_getLogsfor the leg's tokenTransfer(address,address, uint256)with the wallet asfrom/toover the break range; venue secondary events declared per adapter (Aave aToken/debt-token transfers, Morpho vault share transfers andforceDeallocate, Fluid position-NFT transfers and vault migrations, Pendle PT transfers, ERC-4626 share transfers). A hit is decoded through the adapter and persisted as a normal record (re-derive the range); a miss returns null.- Accepted-cause registry
src/lib/portfolio/audit/accepted-causes.ts:native-eth-transfer(native ETH is a derived leg; the audit still records the adjustment,explain_status = accepted, no page). - Budgets and pages per R8, wired to the tick's
[partial]contract and the worker's alert arm.
S6 — Comparator (§7)
S7 — Docs, runbook, fixture, ops
docs/portfolio.md("How performance is measured": the ten shapes → the surviving set + W11; "Product shape": the three records and their roles; "APIs": stamp semantics; "Tracked wallets": enrolment = a job),docs/data-pipeline.md(worker, continuous derivation, the audit),docs/database.md(114, 115, the two kinds),docs/metrics.md(M-rules touched by R5),docs/deployment.md(worker process, crontab changes),docs/processes.md(schedule),docs/ops/release-steps.md(§9 below,executed: pending),docs/external-dependencies.md(the explaineth_getLogsbudget).cd docs && npm run buildgreen (dead-link check) andnpm run plans:index.scripts/fixture/seed.sql:openingrows for the fixture wallets' floor holdings so the fixture exercises R2 withoutheldAtOpen; jobs table empty..github/workflows/deploy.yml+scripts/ops/restart-ingester.sh: restart the worker like the ingester.scripts/ops/reset-portfolio-users.ts: clears jobs for deleted wallets.
6. Byte-identical gate (wave 1)
6.1 npm test (typecheck of both configs + every unit and scenario suite) green with NO scenario expectation changed. 6.2 tests/golden/** unchanged after regeneration (scripts/ops/golden- portfolio.test.ts). 6.3 (S3 only) the job-vs-tick row parity test. A wave-1 PR that needs a golden change has found a defect in the base; it lists it on the PR and stops.
7. Approved-diff gate and comparator (wave 2 + 3)
7.1 Population: an agent selects ~12 real Ethereum wallets that together cover every /portfolio band — idle stables, a single-asset carry (e.g. sUSDS/USDT on Morpho), a cross-currency borrow, a yield-bearing wallet token with a wedge (sUSDe/sUSDS/wstETH), a listed fund holder, a PT holder (one matured in-window if possible), a Fluid smart-collateral/smart-debt position, an Aave e-mode loop, at least one wallet with an in-window liquidation, one that fully exited in-window, one with a plain untracked inflow (the R6 case), one holding native ETH — sourced from refresh-portfolio-discovery candidates on prod (read-only), Dune (getUsage first; cached results preferred) and Herd. The two all-bands test wallets on file are included. Fred does not need to approve the list. The R6 case is not in the population (accepted, S6 prep #954, 2026-09-25). No wallet found holds a movement today's decoders miss. The acceptance reconciler over the 12 enrolled wallets on staging (read-only) finds 324 withholds (W2 322, W3 1, W4 1), no W9 and no W10, and a tip-boundary suppression tally of 0. The one plain inflow from an untracked address (0xcd3b's 60,000 and 36,000 USDC) is already a ledger movement (transfer_in), and every other unrecorded inflow is native ETH, which takes R8b's accepted branch. So in §7.3, R6's "explain finds a transfer and books it" and the unexplained adjustment with its page (R5, R8a) have no real-data case: S5's unit and scenario suites are their only evidence, and §7.4's W9/W10-retirement class is expected to be empty on this population. A wallet with such a movement was not cheap to find: the tracked tokens are standard ERC-20s (stETH and eETH move by their share events), whose movements the ingester decodes, so a blind move on one needs a stream that is not caught up, and enrolment waits for the catch-up. 7.2 Capture OLD: enrol them on STAGING at the base commit (backfill-portfolio-wallet.ts by uid, no account; pause the nightly reseed for the window by commenting its crontab line and restore it after), wait for the drain, snapshot the four surfaces per wallet with the golden serialiser (scripts/ops/golden-portfolio.ts --db staging --wallets …). 7.3 Capture NEW: after the integration branch deploys to staging, re-derive the same wallets (rederive), snapshot again (the order, and its two row-writing exceptions, are in §10's S6 window entry). 7.4 Classify every differing served number into exactly one of: R2, split in two (amended 2026-09-26, PR #961 final review SF-4): R2-moved, a row present under ledger occupancy that the reading gate hid because the leg MOVED after its reading (served at the movement's to_balance, valued at that reading's marks pro rata), and R2-carried, a leg the ledger holds while a reading OMITS it, carried at its last reading (W1) — every R2-carried row is a FINDING with a per-leg cause, never an accepted difference: since SF-3 the audit reads such a leg directly (a STRICT read of the one leg since round 2's B1: one call for its count, never the venue reader), so a carried row is one the direct read found held (a reader failure, which R8c pages) or could not read (a revert, a leg outside the loaded registry, a degraded load, a Fluid position), and the report names which from the worker's log; R2-exit (added 2026-09-26, PR #961 final review SF-3), an exit the ledger missed, read directly: a leg a reading omits while the ledger holds it, whose strict read at the reading's block returned zero, booked as an adjustment to zero, so the row leaves the positions table and the All view's value from that reading on and the activity statement gains its "Balance adjustment −X, cause under review" line (its difference is a correction's display, classed here rather than under R5 because it removes a row; the fixture's wallet B is its one case, 0x2222's closed lend); R5a/b/c (a correction's display); R7 (dust); W9/W10 retirement; S2 hole-fill (a value present where the frozen one was null); and (d1, amended 2026-09-25) late or corrected series data re-pricing a stored row: a line the series as its writer saw it reproduces, and the difference is exactly a bar or rate row that arrived or was corrected after the write (the census's late and corrected classes; this is R12's intent), and (amended 2026-09-25, PR #959 review SF-4) R11, the "Updated" stamp: every summary.json's readAt / readBlock (each wallet's and each account's) moves from the newest reading's block and time to the wallet's derived-through block and the newest recorded time at or below it (its own terms plus the continuous producer's cursor for a wallet the producer follows; null for a wallet whose history is not derived, and for an account holding a wallet whose rows are served with no stamp). The comparator classifies it BY NAME, those two fields of the summary and nothing else: positions.json's readAt is still the reading's time and is not in the class. Anything else is a BLOCKER on the integration PR. Report as a PR comment: per wallet, per surface, the diff and its class. Every unexplained correction on the 12-wallet comparison is a FINDING to investigate before prod step 1, not an accepted class (amended 2026-09-26, PR #961 final review SF-4), R2-exit's included: §7.1 found no movement the decoders miss on this population, so an unexplained correction there is an audit defect, a decoder whose quantities are not exact to the unit (the Aave-family decoder inverts nominal event amounts to scaled units per era and per operation, derive/aave.ts, so an inversion one unit off the contract's surfaces as exactly such a correction, paging at every reading and marking each stretch "not measured"), or an exit no decoder booked; each is explained per leg in the report before step 1. Accepted native-ether corrections are expected, and so is a WETH wrap (named in the release watch). 7.5 (E) The S6 preparation runs as soon as wave 1's row parity is in place: select and enrol the population (7.1, 7.2) and run scripts/ops/value-parity.ts --db-url <staging> read-only over it, so the residue classes S2's amendments must remove are sized on real data before S2 lands.
8. Review protocol
Implementer (Opus 5.5, max effort) → draft PR into the integration branch → independent reviewer (Opus 5.5, max, fresh context; first-, second-, third-order effects and financial soundness; findings as a PR comment) → implementer fixes or explicitly waives every finding → re-review → repeat to 0 blockers → merge into the integration branch. The integration PR to staging gets final-reviewer-max. Reviewers of wave 1 verify the byte-identical gate themselves (re-run §6, never trust the implementer's claim). The reviewer of S4 verifies every scenario restatement against the rulings cell by cell.
9. Release and rollback
The operator's list is the release entry, not this section (#960 review round 3, N5, applied with PR #961's final review): docs/ops/release-steps.md#ledger-first-release adds migration 117, the opening backfill and step 10 (the node provider's key) to the steps below and starts the worker after the backfill. This section is the plan's record of the order as it was decided.
Release steps (one entry in docs/ops/release-steps.md, executed: pending): (1) migrations 114 + 115 + 116 by hand BEFORE the deploy (expand-only; the loader selects the new columns from first start, and the writers insert 116's); (2) deploy (app + ingester restart); (3) pm2 start scripts/worker/run-worker.sh --name creddit-ledger-worker --time --kill-timeout 120000 && pm2 save; (3a) PT stamps (S2 review S16): a PT movement is valued at read from the rate stamped on it at its block (meta.ptRate / meta.ptFactor, since v0.69.0, #940), so a PT row derived before the stamps is unpriced until its wallet is re-derived. Count them, read-only: SELECT f.wallet, count(*) FROM onchain_credit.portfolio_flow_events_v2 f JOIN onchain_credit.pendle_markets m ON m.chain_id = f.chain_id AND m.pt_address = f.asset WHERE NOT (f.meta ? 'ptRate' OR f.meta ? 'ptFactor') AND f.kind NOT IN ('opening', 'adjustment') GROUP BY f.wallet (the two audit kinds carry their reading or nothing, never a stamp, and a rederive keeps every opening, so counted they would name a wallet that held a PT at its floor for good: PR #960 review B2); enqueue a rederive job over the whole window for every wallet it names, wait for done, and re-run it: it must return no rows (expected none on prod, whose one account was enrolled after v0.69.0; the count is the check); (3b) the rate-facts backfill (S2b, scripts/ops/backfill-rate-facts.ts, docs/ops/release-steps.md#ledger-first-rate-facts): a dry run, --apply, and a second --apply that writes nothing; it fills a fact only where the served read then reproduces the stored value of every row the fact turns computed (the row filled, and every other leg of its reading that reads the same rate: the read pools a reading's facts, #957 review S8), so it moves no served number beyond late or corrected series data; afterwards the reconciler's factless= counts what is still served stored (and differ= is above zero from the deploy on: a row whose value reads no rate is computed from the first start and takes the bars that landed after its write, §7.4 d1); (4) crontab: the minutely drain line and the 6h tick stay (R13); add nothing; (5) the freshness cron's new arm needs no crontab change; (6) watch the first ingester cycle → continuous jobs → worker done; press Synchronize on Fred's wallet: refreshed:true, stamp = derived-through block time; (7) watch the first 6h tick: audit lines, zero unexplained adjustments expected on a clean wallet. Rollback (docs/ops/release-steps.md#ledger-first-rollback): pm2 stop creddit-ledger-worker && pm2 save; revert the release commit on main (the deploy restarts app + ingester on the old code); then, as soon as that deploy is green and before the next 6h tick, record the per-wallet counts and DELETE FROM portfolio_flow_events_v2 WHERE chain_id = 1 AND kind IN ('opening','adjustment'), checked to leave none. The delete is required, not cosmetic (corrected 2026-09-26, PR #960 review B1): the old loader skips the rows, but the old events list throws on a kind it does not know (a 500), the old tick's wallet-token universe guard counts them and refuses a range that holds one on a leg nothing moved in it (the accepted native-ether corrections every wallet that transacts has: [partial] every 6h and a pending range that never clears), and the old reconciler pages P4 on one. No migration rollback (expand only; the old code never names 114's or 116's columns). A roll-forward's opening backfill restores the openings, and the full-window re-audit it queues per wallet books the corrections again (a wallet it writes nothing to takes a whole-window rederive). A roll-forward after a revert also first clears the rate facts of every reading the old code re-read at an existing label (its upsert moves the values and the block, not 116's columns), then re-runs (3b) (docs/ops/release-steps.md#ledger-first-rate-facts). Its merge carries a revert of the revert, since a later merge of staging brings back nothing the revert took out, staging ends up carrying the release again, and a fix it carries that moves a served number takes the §7.4 report again over the surfaces it changes (PR #960 review round 3, SF-1; docs/ops/release-steps.md#ledger-first-rollback). The contract steps (drop value columns once factless=0/0/0, which first needs a decision on the two classes the writers keep producing that no fact column holds: a reading over a two-level chain-rated composition, and a PT leg over a composed payout asset, docs/data-pipeline.md#loader-rule; flip the old call sites to enqueue-only; delete the drain crontab line; delete W9/W10 residue) are the NEXT release, after a week of clean audits.
10. Execution log
2026-09-24: plan written; worktree
~/claude/oc-ledger-first, branchfeat/portfolio-ledger-firstofforigin/staging(v0.69.0, c659e74b).2026-09-24: §9 gains step (3a), the PT-stamp re-derive (S2 review round 3, S16).
2026-09-25: S0 merged (#946). Wave 1: S1 #951 approved r1 (5 should-fixes to apply before merge); S3 #949 blocker B4 + 5 should-fixes fixed in r3 (re-review pending); S2 #950 stopped on F1 (synthetic fixture) and F2–F4/F8 (writers read rates on chain): decisions A + b2 + c1 + f1 + d1 taken above, migration 116 added, S2 to be rebuilt on the merged S1+S3 base; E runs as the S6 preparation in parallel.
2026-09-25: S6 preparation (#954). The comparator window on staging is OPEN until the §7.3 NEW capture. Staging (
creddit_staging, checkout/opt/onchain-credit-stagingat 87252a28, v0.70.0 + docs) holds the S6 population exactly as the OLD capture read it:tests/comparator/old/manifest.jsonrecords the ingested tip (26049606), every wallet's newest reading (2026-09-24 18:00Z, block 26048961) and derive cursor, and the input marks NEW's pin check compares (review S3), among them an md5 of each wallet's readings and of its ledger rows (round 3). The window's state, for the §7.3 operator and any other session:- Three root crontab lines are commented out, each prefixed with
# PAUSED-S6 2026-09-24 (ledger-first comparator window; restore after the S6 NEW capture, plan 7.3): the nightly staging reseed (0 3 * * * /opt/onchain-credit/scripts/ops/reseed-staging.sh …, paused 2026-09-24 19:59Z, backup/root/crontab.bak-s6-20260924T195921Z), and the STAGING checkout's minutely drain (* * * * * /opt/onchain-credit-staging/scripts/run-cron.sh drain-portfolio-backfills.ts) and daily fund sync (50 3 * * * /opt/onchain-credit-staging/scripts/run-cron.sh sync-money-market-funds.ts), both paused 21:36Z (backup/root/crontab.bak-s6-20260924T213619Z, installed from/root/crontab.s6-new-20260924T213619Zafter its diff showed exactly those two lines). No prod line was touched. - Restore, after NEW, all three at once, from a file and never through a pipe (in
crontab -l | sed … | crontab -, a failingcrontab -linstalls an EMPTY crontab, prod lines included):bashDry run at 21:36Z: the marker stripped from the live crontab reproducests=$(date -u +%Y%m%dT%H%M%SZ) crontab -l > /root/crontab.pre-restore-s6-$ts && test -s /root/crontab.pre-restore-s6-$ts sed 's/^# PAUSED-S6 2026-09-24 ([^)]*) //' /root/crontab.pre-restore-s6-$ts > /root/crontab.restore-s6-$ts test -s /root/crontab.restore-s6-$ts && diff /root/crontab.pre-restore-s6-$ts /root/crontab.restore-s6-$ts # the diff must show exactly the three PAUSED-S6 lines losing the marker, and nothing else crontab /root/crontab.restore-s6-$ts && crontab -l | diff - /root/crontab.restore-s6-$ts && echo restored/root/crontab.bak-s6-20260924T195921Z(the crontab before the window) byte for byte. - The wallet-token rollout marker was inserted by hand (
event_coverage:wallet-token | * | live | 22527558..22527558, the row prod has, 20:14Z), because the staging scrub deletes it (follow-up in #954). Staging therefore requires the bare-token certificate and runs no ingester: a wallet enrolled on staging during the window waits for a bounded ingester hand-run. The next reseed removes the marker again. - 12 shadow accounts:
accountsrows written the waytrackedAccountUpsertwrites them (created 2026-09-24 20:00:12Z, floor 2026-08-25 00:00Z / block 25828484); the two all-bands rows (0xd775, 0xe51d) already existed and had their floor pair filled. All 12 backfills aredone. - Two other-session wallets (
0xc693…0920,0xebcd…8fc4, registered on staging by another session at 08:53Z) got their bare-token catch-up from 2026-01-01 through the S6 ingester hand-runs. They were not backfilled. - Logs (0600, RPC key redacted):
/tmp/staging-ingester-2026-09-24T200136Z-s6.log(2 cycles, 20:01:36 to 20:05:57Z) and/tmp/staging-ingester-2026-09-24T201437Z-s6.log(8 cycles, 20:14:37 to 20:22:54Z). - Rules until NEW. Nobody runs the staging ingester, a staging backfill, a Synchronize on the population, or a repair, remark or re-judge script against
creddit_staging(scripts/repair/*, or a handUPDATE). Any staging deploy before NEW carries NO data migration (deploy-staging.ymlrunsmigrate.shoncreddit_staging): a schema-only expand file is fine, one that writes rows (a registry edit, a backfill, a re-judge) waits until NEW is captured. Before NEW, only the order below writes the population's rows: its step 4 (rederive) is §7.3's own step, and its steps 2 and 3, the rate-facts backfill and the opening backfill, are the two exceptions to these rules (amended 2026-09-26, PR #960 review round 2 SF-3: the opening backfill joined the order as S4a's follow-up 1). The rules keep the population's rows as OLD read them. OLD's manifest holds an md5 of each wallet's readings and of its ledger rows (review S2 of #954, round 3). A row rewritten in place moves only that digest, and once it is rewritten, the state OLD served can no longer be recorded. - The order before NEW (review S3 of #954; six steps since 2026-09-26, PR #960 review round 2 SF-3). Each step is logged here when it runs. The commands, each with its check, are the release entry's staging order (
docs/ops/release-steps.md#ledger-first-staging-before-new), which runs from the staging checkout with staging's environment and names no pm2 command, and which the prod release waits for.- Deploy the integration branch to staging, schema only. Its migrations (114 to 117) add columns, a table and CHECKs, and must write no row. A file that writes rows waits until NEW is captured.
- S2 took the in-place option (next bullet; S2b's
backfill-rate-facts.ts): its rate-facts backfill of the population's 3,336 readings and 225 receipts, in place, at each row's own block. One of the two exceptions to the no-row-writing rules above. It is logged here with its command, its start and end, and the rows it wrote. It writes the rate facts only (rate_rawandrate_sourceon a reading, the observed rate in a receipt'smeta) and leaves every other column as it is,updated_atincluded. NEW's readings digests are taken over OLD's columns, so they then stay equal, which shows the backfill moved no stored value. And it fills a fact only where the served read then reproduces the stored value of every row the fact turns computed (review B1 of #957): the row it fills, and every other leg of the same reading that reads the same rate, which the read's pooled facts reach too (review S8 of #957); where one of them does not reproduce, that rate is filled nowhere in the reading. So every row it turns computed serves what OLD served, up to late or corrected series data (§7.4 d1), and every row it leaves is served as OLD served it (stored, where no fact reaches it). - The opening backfill of the population (S4b's
backfill-opening-rows.ts, S4a's follow-up 1): oneopeningper holding each wallet's stored floor reading holds, where the ledger has none, written from that reading and never from a new replay, with one re-audit queued per wallet it wrote to. The other exception to the no-row-writing rules above. Without it a population wallet's holdings that never moved are off NEW's surfaces (0xd775's 43 legs are all openings): S4a's release blocker, which applies to NEW as it does to prod. rederivethe 12 wallets (§7.3), and drain the queue with bounded worker hand-runs from the staging checkout until no job isqueuedorrunning.- Capture NEW:
npx tsx scripts/ops/golden-portfolio.ts --db-url … --pin-from tests/comparator/old/manifest.json --label "NEW …" --out tests/comparator/new. It refuses if a wallet's newest reading moved (--overriderecords it) and reports every mark that moved. Expected to move: both tables' column lists (the new columns), the ledger rows' counts and digests (the receipts' rate facts,rederive, S4'sopeningrows) and the derive cursors. A readings digest that moved means a stored reading was rewritten, and §7.4 must explain it. - Restore the crontab, as above.
- For S2 and S4 (review S2 of #954): the population's rows carry no rate facts. All 3,336 stored readings and 225 receipts were written before migration 116, so none has
rate_raw/rate_source, and §3's read rule (the stored rate when present, the series only whererate_source = series) does not say how to value them. Valued from the series, the census's 355 residue lines (the observed sUSDe, sUSDS, syrupUSDC and wstETH rates) become NEW differences that fit no §7.4 class, and at release the same restatement reaches every wrapper holder in prod's window. Either backfill the rate facts in place at each stored reading's block, or read a pre-116 row's stored values. Re-running the enrolment replay is not an option: it has no upper bound, lays a new grid up to the day it runs and moves every newest reading past the pinned 18:00Z, which NEW's pin check refuses. S4'sopeningrows have the same constraint (0xd775's 43 legs are all openings, andrederivewrites ledger rows only), so openings come from the stored floor reading. - OLD is deterministic on real data. Regenerated with its marks by the merged base (240e5a05: v0.70.0 + S1) and pinned to its first capture, and captured twice more into a scratch tree (a fresh process, and under the gate's planner perturbation): all 48 capture files byte-identical every time.
- OLD's row digests (round 3, 2026-09-24 22:51Z). Regenerated by
feat/lf-s698d36dd2 while the window was intact, pinned to the round-2 manifest (the only differences were the 32 new keys) and then to itself (0 differences). Onlymanifest.jsonmoved, and the 48 capture files are byte-identical to the first capture. Before it ran, a read-only check found the newest write of every population row at 20:32:37Z, before the first capture (20:36Z).
- Three root crontab lines are commented out, each prefixed with
2026-09-25: S2b (values computed at read, rebuilt on the S1 + S3 + S2a base). #950's read-side design ported (its seven commits cherry-picked; conflicts only in the test list, one import line each in
reconcile/booked.tsand the loader, and this log). Added migration 116 (rate_raw/rate_sourceon the readings, the fills' consideration andrate_facts; a movement's facts inmeta.rateFacts), the writers keeping the rate each row was struck at (the 6h checkpoint, the live tip, the enrolment replay, the movement resolver, the fills), c1 (the live tip stores no vendor level where no bar exists), the loader rule (computed from the facts, aseriesfact re-reading the series, a fact-less row served stored and counted asfactless=on the reconciler's line) andscripts/ops/backfill-rate-facts.ts(in place, idempotent, dry-run by default, never overwrites). On the fixture: goldens byte-identical to S2a's (onlymanifest.json's provenance moves), the census 0 residue and 0 rows served stored. Review round 1: the backfill fills a fact only where it reproduces the row's stored value (the chain, else the fallback the row's writer had,seriesorlive); a rate two legs of one reading state two ways answers nothing, and a movement is valued at its own facts; a 6h tick'slivefact prices its redemption line only (its market line: the series at its block); the fixture gains a dormant wallet with series-priced legs at a tick and a tip (0xfefe…, goldens byte-identical). The classesfactless=0/0/0cannot reach without a decision are named indocs/data-pipeline.md#loader-rule. Review round 2 (S8): the read pools a reading's facts, so a fact the backfill fills on one leg reaches every leg of that reading that reads the same rate; the backfill now fills a rate for a reading only where every leg it turns computed reproduces its stored value (one leg struck at another rate keeps the rate out of the whole reading), and counts the legs computed through another leg's fact apart from the rows it leaves. Its fill statement also pins each reading's block.2026-09-25 (later): wave 1 fully merged — S3 #949 (b527d1c0), S6 #954 (b26c836a), S2a #955 (a84c80dd), S2b #957 (b79de227), S5-lib #956 (955d0ded); gate 7,060 green; goldens unchanged since S2a's regeneration. Wave 2 = S4a then S4b (amended text above).
2026-09-25: S4a (data model, occupancy, audit, engine), draft PR into the integration branch. Migrations 114 (
opening/adjustment+explain_status,cause,reading_blockand their four CHECKs) and 117 (auditjob kind, priority 25, no settle line). Openings from the enrolment replay's floor reading and the audit's coverage start; occupancy and the served row set from the ledger alone (heldAtOpen,quantityBeforeFirstReceipt,W9,W10deleted;W11 corrected-readingfiled); the reading audit as a worker job (deferred without spending an attempt until the wallet's ledger reaches the reading; explain, re-derive, re-audit; the residue booked as anadjustment, restated rather than stacked on a re-run; R8 throughbudgets.tsand the alarm's fourth arm). 87 approved-diff entries restate the scenario cells in outcome terms (the PR body). Goldens: 10 files changed, 4 R2 (wallet B's closed lend, which the fixture closes with no exit movement, is held by the ledger) and 6 R5 (the audit's corrections on 0xaaaa's flat statement spine and 0xbbbb's inconsistent PT and debt rows, not measured); none R7, W9 or W10. S4a must not reach a release without S4b's opening backfill, run before any audit of the wallets it covers (or followed by a re-audit of every reading it covers): until it runs, a wallet enrolled before this release has no opening rows, so its holdings that never moved leave the served row set, and a holding whose first in-window record is a movement out of a balance no row states (a partial withdrawal from a pre-window position) is not measured over the interval that holds that movement (W6at the unstated opening, paged under W6's zero budget). An audit that runs first books each such missing opening as an unexplained correction at the leg's next reading (paged), and opens every unmoved leg at a later reading, which the floor opening would then duplicate (withheld as a restatement until a re-audit removes it).2026-09-25: S4a review round 1 (PR #958, the review's B1 and SF1–SF10). The audit's ledger is literal: opening + movements from ZERO, never
to_balance − qty_deltaof the first movement (B1); the engine books no series from zero at an unstated opening (W6at leg-interval scope, a barrier) and reads a second opening on a held leg as aW11restatement (SF3); the audit's write is fenced by S3's ledger fingerprint (SF4); a re-derivation (the trigger, arederivejob) queues the audit of the readings it covers (SF5); an audit row whose reading was deleted (a superseded live tip) is moved to the reading that superseded it (SF8); the alarm's page states a correction's size in its asset's units and dollars (SF9); pinned: the merge's audit-row exemption andcloseLegs' resets (SF1, which also found the wallet-token universe's superset guards counting audit rows and refusing a range's whole delete — fixed), R2's moved-after-reading row (SF2, a fixture shape on 0x7171, the S17 wallet: placed on 0x8888 first, it made S3's whole-window re-derivation of that wallet refuse its delete), the Fluid birth read (SF6, W6's production population unchanged by it). The PT lot book's handling of adjustments is S4b's (SF7).2026-09-25: S4a review round 2 (PR #958: approved, 0 blockers; SF-1 to SF-4 applied). A wallet's audits apply in BLOCK ORDER, and each wallet keeps an audited-through watermark (the newest reading an audit compared:
chain_scan_cursors,portfolio:derive:v2:<chain_id>:<wallet>:audited-through, the per-wallet wipe family, monotone up), which is a break's search floor and the orphans' floor, never the previous stored reading (SF-1). A job waits, its attempt handed back, while an earlier reading's audit of its wallet is open (the review's probe (a): a deferred tick audit and a live tip's now book one +50 and page once); an orphan's correction moves to the next reading that stands above it, a recomposed checkpoint no audit compares included (compared for that leg alone, nothing searched, never a floor: probe (b), neverfactless); and a job at or below the watermark that changes a row re-audits the readings above its range through the watermark (a checkpoint the tick commits below a tip already audited: one correction, at the checkpoint). Pinned: both probes, that case, a cell per derived-through term (SF-2), the in-place re-derivation's lock-wait and fence refund through the worker's loop (SF-3). Doc drift fixed (SF-4).2026-09-25: S4b (surfaces and ops), draft PR into
feat/lf-s4(retargeted to the integration branch once S4a merges). The activity statement's line for a correction (R5c: "Balance adjustment +X ASSET, cause under review", or "…, source not tracked" once accepted; its own action class, no capital, no chart flag, no transaction link; anopeningis never loaded). The entered basis (R5b): an adjustment is a receipt at its reading's two marks, a top-up on a wedge leg and nothing on a par leg; a span's firstopeningrow is its opening endpoint; what a leg held before its first receipt is a record's to state (an opening, or a reading below it), neverto_balance − qty_delta, and a first block no record states carries no basis (unstated-holding). The PT lot book (SF7) sets a corrected PT leg to its reading's holding: anadjustedlot at the reading's own rate, or a pro-rata reduction; index-scaled collateral is withheld at the correction. The as-of positions and the All view's value history from ledger occupancy (R2) with the checkpoint rule (a position moved after the day's last reading shows the reading's quantity, and its entered basis stops at that block). The "Updated" stamp (R11) = the wallet's derived-through block (the audit's ownreadWalletDerivedThrough) and the newest recorded time at or below it, on the summary and the refresh response; a wallet still being built states none.scripts/ops/backfill-opening-rows.ts(floor openings for wallets enrolled before this release: dry run by default, insert only, idempotent, fenced, one re-audit queued per wallet it wrote to). Docs, and the release entrydocs/ops/release-steps.md#ledger-first-release, which starts the worker after the opening backfill where §9 started it before any backfill: the worker runs the audits, and S4a's release blocker (above) is an audit that runs before the openings exist. Goldens: 11 files changed, 2 R2 (the All view's value on wallet B's closed lend, now carried at its last reading), 4 R5 (the new balance-adjustment lines on 0xaaaa and 0xbbbb, and their facet and filter) and 5 R11 (the stamp is null on the five summaries whose history the fixture leaves building,backfill: running: no derive cursor); none R7, W9 or W10. R11 is not in §7.4's list and is flagged on the PR.2026-09-25: S4a review round 3 (PR #958: 3 blockers, 2 should-fix, all fixed). Every re-audit compares the readings above its range again through the watermark, whatever the run changed, so a run stopped between its lower write and the restatements above it (a fence, a lock wait, a throw, a restart) leaves no second statement of one difference (B1); the audit's own write that only queued for the write lock goes back with its attempt handed back, so contention parks no audit (B2); an orphan moves only to a reading that read its leg with no movement of the leg between the two blocks, and otherwise stays where it stands, valued from the reading row it now carries (
meta.audit.reading, read by the loader, the reconciler and the census) and compared again at its own block from that reading's quantity: a tip's correction on a leg sold, moved or unread before the next reading is no longer lost, nor booked above a movement it predates (B3); the re-derivation a correction owes is queued in its write's transaction (SF-a); and every refunded wait (the fence, the lock, a waited re-derivation) gives up at the six-hour horizon (SF-b). Found on the way: the reconciler's receipt read never projectedreading_block, so it served every opening and adjustment its stored columns and counted each onefactless; fixed with B3's projection.2026-09-25: S4a review round 4 (PR #958: the round-3 fixes re-checked against the review's acceptance probes). The two probes the review verified by hand are now fixture cells through the worker's own loop: the audit's own write fenced goes back
queuedwith its attempt unchanged (R3-4), and the deferred-tick / live-tip interleaving books one correction and one page (R3-5); so is the pass above a re-audit meeting the write lock (B1 x B2). One more way into B3 closed: a stored audit row of a leg the reading AT ITS OWN BLOCK no longer reads (a tip the 6h tick retired at its own anchor block, a grid point a replay re-laid, with that leg's read failed) was removed by that reading's restatement; the chain at a block does not change, so it is now kept and compared again from the reading it carries. The worker's line no longer calls an audit's wait or fence a lock wait.2026-09-25: S4b review round 1 (PR #959: approved, 0 blockers, 7 should-fix; the programme owner's delegate took SF-2 to SF-6). S4a rounds 3 and 4 merged in first. SF-2, R11's producer term: the "Updated" stamp is derived through the higher of the wallet's own terms and the continuous producer's cursor, for a wallet the producer FOLLOWS: the producer keeps one follow record per wallet (
portfolio:derive:v2:<chain>:<wallet>:followed-after, the per-wallet wipe family), written only when a newly loaded population gains or loses the wallet and raised above a range it skips (every other cycle writes only its own cursor, O(1) as before); the term applies where the record is at or below the wallet's own reach and its replay is not queued or running, capped below its open or failed tick-range job and its pending marker. The reading audit does not take the term (readWalletDerivedThroughis the wallet's own terms; the stamp readsreadDerivedThroughTerms): the producer vouches only for what its candidate read can see, and a movement it cannot tie to a wallet (a routed operate on a position opened within the population's lifetime, a close no log names the wallet in) would otherwise be booked as a correction and paged; the stamp can read later than such a movement until the next tick or page load, stated in the docs. Pinned by unit cells and by the worker soak, which now asserts after every cycle that no wallet is stated derived above a movement its ledger does not hold (32 wallet-cycles lifted by the term; without the job cap, the follow guard or the skip raise it is red). SF-1: an account's stamp is unknown while one of its wallets serves rows with no stamp (accountStamp,stampReadAt; the label says the history is still being built); a wallet with nothing on screen still adds nothing. SF-3:refreshedis false beside every reason; the dashboard re-fetches on the three whose merge ran (nothing-newer,venue-failed,merge-partial). SF-4: §7.4 names R11. SF-5: a correction on index-scaled PT collateral withholds the leg's level (not only its flow) from that reading on, until the ledger states the leg empty. Found on the merge: the activity statement sized a correction from its stored reading only, so one the audit keeps where its reading is gone (S4a round 3, B3) lost its size; it is sized from the reading the row carries; and the opening backfill's openings carry their floor reading (meta.audit.reading) as the replay's now do. SF-6 (counted) and SF-7 (the follow-up list) are on the PR. Goldens: regenerated, no file moved this round (the branch's diff againstfeat/lf-s4is still the 11 files classified R2, R5 and R11 above).2026-09-25: S4a review round 4 follow-ups (PR #958: approved, 0 blockers; SF-1, N1 and N2 applied). The pass above a re-audit ends with the audit rows above the last reading it compared, up to its top, each compared again from the reading it carries: a late checkpoint's correction under a tip an EMPTY reading retired (a wallet that sold everything writes no row, so nothing replaced the tip and no audit came) is booked once, the tip's row removed (SF-1). A pass that never completes (the six-hour horizon, a
failedjob) still leaves the tip's row to the next audit that reaches it, which for a wallet that holds nothing is only its re-derivation's. Pinned: the follow-up re-derivation commits with its correction (stopped at the first statement after the COMMIT it is already queued; with the COMMIT lost it is gone with the correction, N1); the hourly arm sizes a kept row from the reading it carries, not its raw count (N2).2026-09-26: Fred ruled on the accepted-correction display (R5c amendment above); implemented in S4b's final round.
2026-09-26: S4b review round 2 and the R5c amendment (PR #959: 1 blocker and 3 should-fix, all fixed; Fred's accepted-correction ruling above implemented). B1: a tick-range job whose merge the ingested tip clamped below its top (a stream rolled out while its cursor was behind, a cursor rewound by hand) ends
partialand now marks its wallet pending from the merged top + 1, the tick's own clamp rule (pendingMarkFrom), so the stamp's producer term stops at what the job merged instead of passing a movement the ledger does not hold; the review's probe is a fixture cell through the real producer, worker and stamp reader, quiet cycles and a held wallet included (SF-C). SF-A: a PT correction whose reading is gone is priced from the reading it carries (meta.audit.reading, projected as the receipt'sauditReading), so its lot is valued rather than withholding the leg's level for the lot's life. SF-B: the stamp's API contract states where it can run ahead of the ledger (a movement the producer's candidate read cannot tie to the wallet, and native ether, which no log moves). R5c as amended: anacceptedcorrection is held out of the statement's own lines by default and folded into one summary line, "N balance adjustments, sources not tracked", beside the fees' control (adjustments=1opens it;foldedAdjustmentscounts it through the page's filters, first page only, absent when zero); anunexplainedone always shows individually; folding revalues and recounts nothing. Goldens: regenerated, no file moved (the fixture books no accepted correction, and a statement read without the new parameter is served byte for byte as before).2026-09-26: S7 (ops, runbook, carried nits), draft PR #960 into the integration branch. The deploy restarts the ledger worker as it restarts the ingester (
restart-ingester.sh, both paths: a stopped process left stopped, a refused restart a red run; a missing worker a warning whileWORKER_OWNS_DERIVATIONis false and an error once it is true, read from the deployed checkout, because this release deploys before its worker is started), and the freshness arm notes a worker whose grace period is not 120 s. The user wipe clears the derivation queue for every wallet no account holds and refuses--executewhile a worker holds its lock (#949 base gap 3); the deleting-users runbook, every account or some wallets, isdocs/deployment.md#deleting-portfolio-users(the partial delete selects a wallet's cursor rows by the scope's fifth part, which the 2026-09-22 hand delete's last-42-characters match did not). The ingester's start line redacts its endpoints (#954 follow-up 1). The release is ONE entry,docs/ops/release-steps.md#ledger-first-release: §9's order with every sub-PR's server step, the worker started after the opening backfill as S4b set it, the rollback and roll-forward, the staging order before NEW (the opening backfill added, S4a follow-up 1; #954's crontab restore verbatim), and the contract release's list. Nits: #946 N1 (the gate's bound), N2 (dead scratch databases swept at build), N4 (the network guard as the harness's first import), SF3 (the seams sentence), #956 round-2 SF-1 (the checksummed-copy cell). No served number moves: goldens byte-identical.2026-09-26: S7 review round 1 (PR #960: B1, B2, SF-1 to SF-5, N1 to N5). The rollback's delete of the two kinds is a numbered, required step right after the revert's deploy (B1: the old events list, the old tick's universe guard and the old reconciler do not tolerate them; R13 and §9 corrected, the per-wallet counts recorded first and the roll-forward's re-audit re-booking the corrections); the PT-stamp count leaves the audit kinds out (B2, §9 (3a)); the contract release's list carries #949's remaining flip and later-wave items (SF-1) and the open WETH wrap decision (#956 F1, SF-2, also named in the release watch); the release entry says the hourly alarm pages until the worker starts (SF-3) and that staging's hand-runs go one at a time with a small pool (SF-4); §11 lists what the merged sub-PRs carried that no list held (SF-5). Docs only, and one test comment: no served number moves.
2026-09-26: S4b review round 3 (PR #959: 1 blocker and 1 should-fix, both fixed). B2: a 6h tick whose own top (min(anchor − 64, ingested tip)) stopped inside a clamped job's unmerged range cleared B1's marker as its covered merge reached it, and the stamp's producer term then passed a movement between the two tops that the ledger did not hold (the review's probe: stamped B0+160 over a ledger stopping at B0+100). The term now counts a
partialtick-range job as owed until the wallet's own terms reach its top (PRODUCER_TERM_SQL, the wallet's own derived-through as its fifth parameter), whoever clears the marker, which stays for the tick to widen down to. Pinned by the B1 / SF-C cell, extended with the review's two ticks over [B0+1, B0+145] (the lagging stream caught up to B0+145, and every stream caught up with the anchor there): the marker cleared, the stamp held at B0+120, then the next tick derives the movement and the stamp moves on. Three cells restated: that cell's terms tuple, the audit-job SF-2 Postgres cell (a partial job is owed below the wallet's own terms and not at them) and the wallet-stamp SF-2 cell's parameter list. SF-1: the stamp's contract (api-types.ts,wallet-stamp.ts,docs/portfolio.md,docs/data-pipeline.md, and the job states indocs/database.md) now says a job that stops short holds the stamp until the wallet's own terms reach the job's top. Goldens: regenerated, no file moved.2026-09-26: S7 review round 2 (PR #960: approved, 0 blockers; SF-1 to SF-5 and N1 to N5 applied, on the base with S4a #958 merged in). The release entry opens with its precondition: the staging order done through NEW, every OLD/NEW difference classed, the crontab restore done or scheduled (SF-1). The staging order's steps 2 to 4 have their own commands, run from the staging checkout over the population read from OLD's manifest, with no pm2 command, so none can reach prod's worker (SF-2). §10's window rules and order are the six steps with two exceptions, and point at the entry (SF-3). The node provider's key is release step 10, Fred's decision (SF-4). §11 carries the merged sub-PRs' follow-ups no list held (SF-5); S4a's notes to S4b join it at S4b's merge. Nits: the revert's command, the roll-forward's queue cleanup, the log rotation's scope, the reseed the crontab restore turns back on, and three checks written out. The merge's follow-through: the derive-lag arm leaves audits out, the alarm row's fourth arm, 114's index link and revert wording (its SQL header waits for a golden regeneration, §11), and the selector pin names the audit's watermark. Docs, one test cell and two comments: no served number moves, goldens byte-identical.
2026-09-26: S4b review round 4 (PR #959: 1 blocker and 1 should-fix, both done). B3: a page load's reading lifted the wallet's own derived-through over a stretch the 6h tick had marked owed. The tick, its merge for the wallet failing, marks the wallet pending at its range's bottom and still advances its cursor; the next page load merges only above that cursor (site
jit, which never absorbs a marker), the store gate admits its reading, and the live tip then stated the wallet derived through the reading's block over a ledger missing the marked movement (the review's probe: a transfer refused at B0+50, stamped B0+120 over a ledger holding nothing above B0; the audit of that reading books the transfer as an unexplained correction, and pages). The live tip, and adonesyncjob's top (the page load's step once the worker owns derivation, whose range does not reach down to a marker either), now count only below the wallet's pending marker, the tick term's own rule (readDerivedThroughTerms,DONE_JOB_TOPS_SQL). The cost, as the review states it: while a marker stands, the stamp and the audit of the wallet's page-load readings wait for a derivation that starts at or below it (the next tick, the wallet's continuous job), and a page-load audit that outwaits its horizon gives up. Pinned: the probe as a fixture cell through the real producer, worker loop, tick step, page-load writer, store gate, audit and stamp reader (sweep-parity.test.ts, "B3 (PR #959 review round 4)"), carried on to the flip'ssyncjob and to the retry that derives the movement and releases both; and the audit-job derived-through cell, extended by a step. The contract line (a page load's reading counts only up to the lowest block the wallet still owes, its pending marker) is inapi-types.tsandwallet-stamp.ts, and the terms are restated inlive.ts,job-runner.ts,docs/portfolio.md,docs/data-pipeline.mdanddocs/database.md. SF-1: the activity feed's request options and first-page merge are pure helpers (feedRequestOptions,mergeFirstPages), pinned by two cells: opening the summary line reaches the request (M10) and its count is the server's (M11). The render half (M9) waits for a fixture wallet that books an accepted correction (follow-up 10). Goldens: regenerated, no file moved (the fixture holds no pending marker).2026-09-26: S7 review round 3 (PR #960: approved, 0 blockers; SF-1 and SF-2 applied). The roll-forward's merge carries a revert of the revert, made on a branch off
mainor first onstaging(before a fix lands there), because a later merge ofstagingbrings back nothing the revert took out (checked in a scratch repository);stagingends up carrying the release again, the rollback keeps the revert off it meanwhile, and a fix the roll-forward carries that moves a served number takes the §7.4 report again over the surfaces it changes (SF-1). The release watch is re-diffed against S4b's head (#959,dfc91846): the producer's first run on prod only starts it, so the follow records and thenow followingline come from the next cycle (on a roll-forward its cursor survives and nothing starts); a Synchronize'sreadBlockcan stay below the tip it stored while the wallet owes an earlier stretch (its pending marker, #959 round 4 B3), and a quiet wallet's follows the ingester once that stretch is derived (SF-2). Docs only: no served number moves, goldens byte-identical.2026-09-26: S4b review round 5 (PR #959: approved, 0 blockers; 2 should-fix, both done). SF-1: a read of the wallet's pending marker that fails is no longer taken as "no marker".
readDerivedThroughTermsnow leaves out every term the marker caps (the tick's cursor, adonesyncjob's top, the live tip and the stamp's producer term) and keeps what it does not cap (the derive cursor, adonejob that started at or below the marker). So the stamp and the audit's gate stay at the last block the records vouch for. The review's probe: one dropped connection at the audit's gate let the audit of B3's page-load reading pass, book the refused transfer as an unexplained correction, and page. The same premise failed open inlive.ts: a page load whose read of the 6h tick's cursor failed (as opposed to finding none) merged from its last reading + 1 alone, skipping (cursor, checkpoint] with no marker, and could store its reading as a live tip. It still merges, but stores no reading (merge-partial), and once the worker owns derivation it queues nosyncjob from that floor. Pinned by a stub-client cell (wallet-stamp.test.ts: the marker's statement alone fails; terms, gate and stamp stay at the last certain block), a store-gate cell and a wiring cell (live.test.ts). SF-2:docs/portfolio.mdnames what leaves a stretch owed (a six-hourly check, a background derivation, or an earlier refresh that found the write lock busy) and what brings the stamp back (the continuous job the stored reading's own audit asks for, else the next six-hourly check). Goldens: regenerated, no file moved.2026-09-26: Final review of the integration PR (#961,
final-reviewer-max: 2 blockers, 4 should-fix, 5 nits; every one fixed or carried, answered on the PR). B1: CI'scheckjob could not finish in 15 minutes once the golden gate and the fixture suites joinednpm test(run 36214004750, cancelled). CI now runsnpm testas two jobs:check(both typechecks andnpm run test:unit, limit 20 minutes) andfixture-suites(npm run test:fixture, the six suites that build a fixture database, limit 30), measured on the split's first run at 6 and 13 minutes, the golden gate five of them.npm teststays the local entry point and one list;scripts/ci/test-halves.mjssubtracts the second list from it, and the manifest test pins the partition. B2: this header isexecuting, and release step 11 says what the release's follow-up sets it to. SF-3: a count leg the ledger holds and a reading omits is read directly through its venue adapter'sreadBalanceat the reading's block. Zero is the exit the ledger missed: a break of minus its quantity, explained like any other, its residue an adjustment to zero valued and sized from the newest reading that read the leg, whose copy names that reading's block (meta.audit.directRead). Held is a reader failure (R8c), unreadable keeps the carry. An exit already booked at a block is re-planned from its row and never read again; one whose reading is gone is compared again as what its block read, and never moved to a later reading. The fixture's chain states the answers (fixture.chain_balances): wallet B's closed lend is held over its observation hole and gone at i = 301, so the fixture books one exit and wallet B's USD headline, positions and All history agree. Goldens: regenerated, 8 files moved besides the manifest, all in the new §7.4 class R2-exit (0x2222's positions, history, summary, activity and its filters; all-0x1111's positions, history and summary). SF-4: §7.4 splits R2 into R2-moved and R2-carried (every carried row a finding), adds R2-exit, and makes every unexplained correction on the 12-wallet comparison a finding to explain before prod step 1. SF-1: the audit's insert and restatement stampupdated_at = clock_timestamp(), after the write lock, and the hourly arm reads up to five minutes before its look (AUDIT_ARM_LAG_MS) and stores that top as its cursor; the review's probe and the commit-window case are fixture cells, each red with its half reverted. SF-2: §11 carries every open follow-up, one line each. Nits: N1 (the partial-delete pin names the follow record), N2 (the PR body), N4 (release step 3: the 6h reconciler until step 4) and N5 (the runbook's fail-safe sentence on a registry edit) applied; N3 (an audit-coverage alarm term) carried in §11. #960 round 3's N5 (§9's pointer to the entry) applied; its other nits are in §11.2026-09-26: Final review of the integration PR, round 2 (#961,
final-reviewer-max: 1 blocker, 2 should-fix, 3 nits; all fixed). B1: round 1's direct read went through the venue adapter'sreadBalance, whose "no leg" a zero shares with every leg a reader skips (a failed inner read, a leg outside the loaded universe, an Aave-family debt-bearing account dropped whole, D4), and booked that answer as the exit, over a registry the worker loaded once per process and never checked for degradation; the review's probe booked an unexplained −100 USDC exit over a degraded registry. The direct read is now a STRICT single-leg read (adapters/strict-read.ts): oneeth_callfor the count the key names on the venue readers' endpoint (Aave-familyscaledBalanceOfon the reserve's aToken or variable debt token, Morphoposition(id, wallet),balanceOfon a vault or a PT, the wallet row's own call,eth_getBalance), where a revert, empty or short returndata, a leg outside the loaded registry, a degraded load (assertRegistriesComplete) and a Fluid position (no strict read yet, §11) arefailed, and only a returned count is a count. The audit's dependency takes that answer type (StrictLegRead,readStrict), which has no "no leg", and the job maps anything but a count to unreadable. The worker's bindings load each chain's registries per chain, reuse a load for a minute (AUDIT_REGISTRY_TTL_MS) and never reuse a rejected one; the explanation's binding had the same once-per-process cache and takes the same loader. Pinned: the review's probe (a degraded registry, then a complete one lacking the leg: nothing asked, nothing booked), a D4 account (the strict read finds both legs held: nothing booked, R8c), a returned zero through the production binding (the exit, asked with the token's ownbalanceOfat the reading's block), the reader's "no leg" wired by mistake (unreadable), and a read that throws; the four ZERO cells answer a strict zero; the strict read and the bindings have unit suites. SF-1: the snapshot writers stamp readingsupdated_at = clock_timestamp()(the tick's and the page load's insert and its conflict update, the backfill's insert), so a checkpoint whose commit queued behind the write lock across a look is judged by the next look; a fixture cell drivescommitTickWritesthrough the race and its rewrite. SF-2: a restatement whose write waited on the lock across a look is paged by the next look (a cell like 1454), and the UNREADABLE cell runs a read that throws. Nits: N1 (everyassert.okinreading-audit.test.tscarries a message), N2 (a cell pins that a look judges a reading once), N3 (the docs name the endpoint the reads use). Goldens: regenerated, no file moved (planFixtureAuditalready modelled a strict read).2026-09-26: The staging order before NEW, run, and the §7.4 report (PR #962,
feat/lf-comparator,docs/ops/release-steps.md#ledger-first-staging-before-new). Every hand-run from/opt/onchain-credit-stagingwith its own.env.local,PG_POOL_MAX=4and the population read from OLD's manifest, one at a time; logs/tmp/staging-*.log(0600, no secret in any).- Deploy: staging at
d1f60208(the merge of #961);114to117applied by its deploy at 07:59Z; the app answers 200; the threePAUSED-S6lines present. - The opening backfill (taken before the rate facts, in prod's order: the two are independent), 08:13:38 to 08:13:58Z. Dry run:
openings to write 97, already present 0, dust (no row) 7; re-audits queued 0. Apply:openings written 97, already present 0, dust (no row) 7; re-audits queued 12. Again:openings written 0, already present 97, dust (no row) 7; re-audits queued 0. 0xd775 holds 42 openings; its 43rd leg (1e-7 WETH) is dust. - The rate-facts backfill, 08:14:11 to 08:15:24Z. Dry run:
readings: scanned 3336, lacking a fact 471, would fill 471 (facts: 471 chain); movements: scanned 322, lacking a fact 13, would fill 13 (facts: 13 chain); pre-window fills: scanned 5, lacking a fact 5, would fill 5; 225 chain read(s), 0 write statement(s). Apply: the same filled,17 write statement(s). Again:0 write statement(s). No row left; the readings'updated_atdid not move (newest 2026-09-24 20:32:37Z). It scanned 322 movements rather than 225 because the openings were in. - The worker, then
rederive. A first bounded hand-run (08:15:45 to 08:16:10Z) drained step 2's 12 audits and the onecontinuousjob an audit queued. Then the INSERT (INSERT 0 12,[25828484, 26049606], priority 30) and a second hand-run (08:16:58 to 08:19:47Z): 12rederivedone(0 written, 0 removed each) and their 12 re-auditsdone(0 booked). Final queue:audit23doneand 1partial,continuous1done,rederive12done; no-> failed, nothingqueuedorrunning. Booked: 97opening, 45acceptednative-eth-transfercorrections (8 wallets) and oneunexplained(F1 below), on which job #4, the first audit of 0x53e2, endedpartial. The PT-stamp count (release step 7's): no rows. Onerederivebought a Dune fetch-through (0.58 credits) for a token with no bar and wrote none. - NEW captured 08:20:58Z from a laptop through an SSH tunnel by
feat/lf-comparatoratd1f60208. The pin check passed with no refusing difference (no--override); its 31 reported differences are all of the expected kinds (both column lists, every wallet's ledger-row count and digest, five derive cursors); no readings digest moved. - The crontab restored at 09:21:46Z with #954's recipe: the diff showed exactly the three
PAUSED-S6lines losing the marker, the result is byte-identical to/root/crontab.bak-s6-20260924T195921Z, andcrontab -lmatches the installed file. The 03:00 reseed runs again from 2026-09-27: release before it, or redeploy staging after it (the reseed restores prod's schema, which lacks114to117).
The §7.4 report (
tests/comparator/report.md, generated byscripts/ops/comparator-classify.tsfrom the two trees andtests/comparator/evidence.json, which it checks is NEW's state): 1,282 differing numbers in 34 of the 48 files, each recomputed from the rows: R7 1,121 (seven sub-cent legs the ledger does not hold), R5c 66, R5a 57, R11 24, R2-moved 2 (the as-of checkpoint), and BLOCKER 12: B1, the entered basis anchors on anopeningrow's marks where §C5 withholds the financed group's line on the reading (two Morpho debt legs, on 0x5ad6 and 0xe51d). No R2-carried, R2-exit, W9/W10, S2 or d1 difference. F1: 0x53e2's unexplained +0.0001 USDT correction on its wallet leg (reading 25857182) is the floor reading's own 100-raw dust, which R7 gave no opening, surfacing once a 100-raw transfer made the literal ledger non-zero: an audit defect that pages on prod for every wallet with floor dust it later moves. F2: job #4partialon it.The screenshot pass (0xd775, 0xe51d and 0x53e2 at 1360, 1140 and 900, positions and activity, with the page's own refresh answered in the browser so nothing was written): the stamp, the All view, the positions and the activity statement (the correction's own line, the folded "3 balance adjustments, sources not tracked" and its expansion) render and agree with NEW. Noted: balance units truncated ("stE…", "U…") and stacked smart-pair cells flush with their row borders (both pre-existing), and the stamp's date shown only on hover.
- Deploy: staging at
2026-09-26: The comparator run's findings, fixed (PR #963, draft into
staging; the check of #962: B1, F1 and its should-fixes, and the nits). B1: anopeningrow's marks are read, for the entered basis alone, as the reading they copy is served: where §C5 (withFinancedGroupLineWithhold) withholds a financed group's line at the opening's reading, the opening carries that line blank (ledger-v2-api.tsopeningsAsServed), soapplyOpeningreaches the readings' verdict (no anchor, M9's dash, not reconstructed). That is the 12 unclassified numbers on 0x5ad6's and 0xe51d's Morpho debt legs; the engine and every other reader of the opening keep the stored row. F1: anopeningis written for every holding a reading finds, whatever it is worth: the audit's coverage start (auditReading), and through it the enrolment replay's floor openings andbackfill-opening-rows.ts. R7 governs what the surfaces show and what the audit books as a correction, never what anchors the ledger. The audit then finds 0x53e2's 200 raw USDT equal to the ledger's 100 + 100, and the re-audit the backfill queues removes the +0.0001 USDT correction the first run booked (the restatement's remove path), paging nothing again; 0x0957's USDC leg is held at 1,000 raw by the audit and the positions logic alike. The surfaces keep a row off the page while it is worth under a cent and has booked under a cent on both lines (shownLegRows/hiddenAsDust: the positions table, the uncharted band, the divergence list, the "Not covered" count, and the All view's total and its value line, per point). A book's own value line and headline keep what such a leg is worth: under a cent per line per hidden row. A holding withdrawn to a residual that booked a cent or more keeps its row, so the "Yield earned" column still foots (0xe51d's 1 raw USDT with its +$0.26 is one). The backfill's summary counts the dust among what it writes (openings written W (of which dust, hidden on the surfaces, D)), where it saiddust (no row) D. A second-order effect the re-capture shows where it applies: a leg whose first reading was dust and that later grows with no recorded movement is now a break (explained first, R6; native ether takes its accepted cause and folds), not a second coverage start. Nits:asof.ts's header states the S4b checkpoint rule for the rows and their entered basis (the D+1 00:00 bound is the events' and the activity's). The classifier's three nits (the no-change sentence narrowed to the history it walks, the no-rule classes named in the class table, the R5a/R7 coincidence on 0x53e2's USDT leg noted) are on #962's branch, with its report regenerated from the same captures. Goldens: regenerated, no file moved (no fixture leg holds dust, nor an opening at a §C5-withheld reading). Next: the staging re-run of the opening backfill (the runbook's staging step 3 says what it writes) and the NEW re-capture, whose classification needs the classifier's R7 model moved to this rule (the ledger holds the dust; the rows and the All view leave it out; the denomination books keep it). Review round 1 (approve, 0 blockers; its four should-fixes applied):bookedReturnIndex's boundary is pinned by two cells, each red under one off-by-one; R8c no longer pages for a leg that is not a count whose newest reading was under R7's floor (droppedAsDust, §11's "non-count leg absent while held"); R7 carries its clarification in §1; the 0x5ad6 cell states B1 as of a day too. The classifier's R7 model moved to this rule on #962's branch (846264ae): the evidence the re-capture reads names NEW's R7 (newR7: held), and the committed report is unchanged.2026-09-26: The staging re-run on #963's fix, NEW re-captured, and the §7.4 report again (PR #962,
feat/lf-comparator; the runbook's staging step 3, "on a re-run over the state the 2026-09-26 run left"). Staging ateaa016e3(#963's merge, deployed; the app answers 200;114to117in place); the queue idle before (audit 23doneand 1partial,continuous1,rederive12); the crontab restored (noPAUSED-S6line) and the 03:00 reseed held off by/root/.reseed-paused(present since 10:04Z). Every hand-run from/opt/onchain-credit-stagingwith its own.env.local,PG_POOL_MAX=4and OLD's population, one at a time; logs/tmp/staging-rerun-*.log(0600, no secret in any).- The opening backfill, 12:44:28 to 12:44:47Z. Dry run:
openings to write 7 (of which dust, hidden on the surfaces, 7), already present 97; re-audits queued 0. Apply:openings written 7 (of which dust, hidden on the surfaces, 7), already present 97; re-audits queued 7. Again:openings written 0 (of which dust, hidden on the surfaces, 0), already present 104; re-audits queued 0. The seven are the first run'sdust (no row) 7: 0x0957's 1,000 raw USDC, 0x3c3a's 7.4e-7 WETH Aave supply, 0x53e2's 100 raw USDT, 0x5ad6's 1 wei stETH, 0x852d's and 0xd775's 1e-7 WETH, and 0xe51d's 0.000668 USDC in vault 0x0932; 0xd775 holds 43 openings. - The worker until idle, 12:45:06 to 12:45:17Z (
stopped after 8 job(s)): the seven re-audits (#38 to #44)done, each booking nothing; #40, 0x53e2's, loggedreading @25857182: agree=5 break=1; 0 row(s) booked, 0 restated, 1 removedand queued the rows above it (continuous#45 over [25857183, 26049606]:done, 0 written, 6 unchanged, 0 removed). No-> failed; a minute later nothingqueuedorrunning(audit 30doneand 1partial, job #4 of the first run;continuous2done;rederive12done). The adjustment rows go from 46 to 45: the one change is the removal of 0x53e2'sunexplained+100 raw USDT at 25857182 (F1); the 45acceptednative-ether corrections are untouched (sameupdated_at), and no reading was written. The PT-stamp count: no rows. - NEW re-captured 12:47:18 to 12:47:54Z by
feat/lf-comparatorat8fc40ec6(its merge ofeaa016e3), replacing the 08:21Z capture: the pin check passed with no refusing difference (no--override), its 31 reported differences the same kinds as before (the ledger-row counts and digests now carry the seven openings and the removal); no readings digest moved.
The §7.4 report, regenerated (evidence read 12:48:10Z,
newR7: held): 512 differing numbers (was 1,282), every one reproduced: R7 439 on 8 wallets (the rows, the All view and the divergence list only, no denomination book), R5c 48, R11 24, R2-moved 1 (0x53e2's as-of Aave USDC debt; its as-of wallet USDC row is now R7's), R5a 0 (was 57) and BLOCKER 0: B1's 12 numbers equal OLD's again (null and false), so they are no longer differences. Nounexplainedcorrection is left on the population; the one finding is job #4'spartial, which the re-audit #40 (done) supersedes.- The opening backfill, 12:44:28 to 12:44:47Z. Dry run:
11. Carried follow-ups
What the merged sub-PRs left open that is neither a step of this release (docs/ops/release-steps.md#ledger-first-release) nor the contract release's (#ledger-first-contract-release, which also holds the decisions that release needs). No issue is opened for any of them (AGENTS.md); each is its own reviewed change when taken. S4a's (#958) and S4b's (#959) open follow-ups, #960 round 3's nits and the integration PR's final review (#961) are carried in the first five groups below, one line each (PR #961 final review, SF-2).
Decisions with Fred.
- Incoming ether in the fold (#959 rounds 3 and 4, N3): whether an accepted correction that ADDS native ether folds into the summary line, or only one that takes it away. Today both fold, so a WETH unwrap shows the WETH leaving as a visible "cause under review" line and the ether arriving only inside the folded line. Closed by issue #966 (2026-09-27): native ether has receipts (the block feed's transfers and fees, WETH9's wrap and unwrap as one internal move, a contract's payment landed from the audit's transfer listing), so incoming ether is a transfer line and an unwrap is one "Unwrap" line with no correction on either leg. An accepted ether correction is left only where a source could not answer for the stretch, and both directions keep folding.
- WETH wrap and unwrap (#956 F1, carried in the contract release's list,
docs/ops/release-steps.md#ledger-first-contract-release): decided and built by issue #966: WETH9'sDepositandWithdrawalride thewallet-tokenstream (claimed from WETH9 alone) and the wallet book decodes each as one internal move; the release's re-derivation (its entry) replaces the corrections they left. - R2's never-read leg (#959 follow-up 5), closed by SF-3 as far as the ledger goes: the audit reads a leg the ledger holds and a reading omits directly at that reading's block; zero books the exit, so the ledger holds none and no row is owed, and a held leg pages as a reader failure (R8c). What stays is a leg the chain holds that no reading has ever read: with no reading it has no book, category or marks, so it is served nowhere until one reads it. That is the deviation the plan header's
currentnames at the release.
Surfaces.
- The denomination headline against the All view and the positions table (#958 follow-up 3, #959 SF-7). Closed for a missed exit by SF-3 (wallet B agrees on all three). Open for a leg a reading misses while the chain holds it: the positions table and the All view carry it at its last reading, the USD and ETH books'
bookValue, headline and chart leave it out. /api/portfolio/eventsleaves out both audit kinds (#959 follow-up 4), so an agent reading movements never sees a correction.- The client's re-fetch blind spot for a joined refresh (#959 follow-up 3), widened by B3's cap: while a marker stands, a joined refresh that stored a reading no longer triggers a re-fetch, and the joiner's page shows the previous stored view until its next fetch.
- The summary reads its stamp beside the wallet (
Promise.all, #959 follow-up 12), so the stamp is not a lower bound on the same response's rows.
- The denomination headline against the All view and the positions table (#958 follow-up 3, #959 SF-7). Closed for a missed exit by SF-3 (wallet B agrees on all three). Open for a leg a reading misses while the chain holds it: the positions table and the All view carry it at its last reading, the USD and ETH books'
Tests.
- The e2e "a view whose position CLOSED reports what it earned too" (
tests/e2e/portfolio.spec.ts,closedPositionUnit: "USD"; #958 follow-up 2), red on the goldens S4a left, which served wallet B's closed lend as held. SF-3 restores the shape it expects (the USD positions empty, the USD view keeping its earnings), so it is expected green again; e2e is outside this programme's runs, and CI's browser jobs confirm it once the PR is marked ready for review. - The fold's e2e cell (M9, M10c and M11c; #959 follow-up 10: no fixture wallet books an accepted correction) and the screenshot pass of the folded line (#959 follow-up 11).
- The e2e "a view whose position CLOSED reports what it earned too" (
Ops and scale.
- The 14-day prune of
partialtick-range rows (#959 follow-up 13, round 4's N2). - The lock-busy cost (#959 follow-up 14): after a lock-busy skip, B3's cap holds the stamp below the skip's marker until the continuous job the stored reading's audit asks for lands. A cost, not an error.
- A quiet wallet's audit forces a continuous job per tick (#958 follow-up 5), and the audit arm recomputes reader histories per wallet every hour.
reconcile/conditions.tspages "four" where its test titles the list "five" (#958 follow-up 6).ledgerQuantityAt's positive-tolerance branch (#959 round 5 note): a latent double-booking for a tolerance leg with running-sum rows, which a comment or a decoder cell would record.- Audit coverage has no alarm of its own (PR #961 final review, N3): a failed enqueue is one log line, an audit that outwaits its horizon ends
partialsilently, and derive-lag leaves audits out of its queue age. An arm term comparing each wallet's newest stored reading with its audited-through watermark would catch the audit silently stopping. - A non-count leg absent while held keeps the carry (PR #961 final review, SF-3): the direct read is asked of counts only, since a smart pool member or an escrow claim can read zero by drift. A whole smart position closed by a transaction no decoder booked therefore stays carried; closing a pair as a unit when every member reads zero is the follow-up. Its R8c page (PR #963 review SF-2): since F1 opens dust, a member first read under a cent is held, and the Fluid reader drops one that rounds to nothing (
pushSmartLeg), so it paged "unread at 2 consecutive readings" at every reading after. Such a leg whose newest reading was under R7's floor, and whose quantity in the ledger is still under it at that reading's marks, no longer pages (droppedAsDust; the job's log line names it). One worth a cent or more still pages, and so does a whole side the reader failed to read, through its member worth a cent or more. - SF-1's race, closed in PR #961 (the audit's page lost to the write lock): the audit stamps its rows when it writes them and the hourly arm's window stops five minutes short of the look; since round 2 (SF-1) the snapshot writers stamp the readings the arm picks its wallets by the same way, so a checkpoint that queued behind the lock across a look is judged by the next one.
- A Fluid leg has no strict read (PR #961 final review round 2, B1): the audit's direct read of a missed leg is a strict single-leg read (
adapters/strict-read.ts), and a Fluid NFT position has none yet, so a Fluid count leg a reading omits keeps its carry and pages R8c as a reader failure, as before SF-3. Its read is the resolver'spositionByNftIdfor the key's NFT, with the NFT's owner at the block checked against the wallet (a transferred NFT is an exit too). - Dust behind a recorded exit (PR #963, what F1's fix leaves): where the ledger holds a leg at zero after its history and a reading finds less than a cent of it, R7 still books nothing and writes no row, so a later movement on that leg states a
to_balancethat includes the dust and the audit books it as a correction. Only a movement no log states leaves dust behind a recorded exit (a decoder gap, which the explanation finds); none is on the population. An opening at that reading (the leg's coverage starting again) would close it, at the cost of a correction if that dust later vanished with no log. - A matured PT the page does not list keeps its Activity caption (PR #963): the statement's maturity lines read the ledger's rows with no engine (
loadRebuiltMaturities), so a bare PT worth under a cent that booked under a cent is off the positions table (R7) and still named as matured and held in the activity.
- The 14-day prune of
The release entry: #960 round 3's nits.
- Staging step 6 says "release before the next reseed": it is before the 02:15 backup, or a staging redeploy after the reseed (N1).
- Staging step 4: the hand-run's refusal line names prod's worker (
pm2 stop creddit-ledger-worker); there it means another staging hand-run is still going (N2). : "${W:?}"at the head of each staging step's block, so an empty$Wcannot widen both backfills to every staging wallet (N3).- Staging step 5: name where NEW is captured (a checkout of the integration branch, against
creddit_staging) and wheretests/comparator/newgoes (the integration PR, for the §7.4 report) (N4). - §9's pointer to the release entry (N5): applied with PR #961's final review, above.
- The three S4a items N6 names are carried under "Ops and scale".
Writers, before an outside cohort.
- A cross-book consideration in a composed asset is priced in its own book (#957 base defect 1).
planMarkfiles the consideration under the leg's book whileapplyUnitPricescomposes it in its registry book, so 1,000 sUSDS paid for an ETH-book leg values at 1,200 ETH instead of about 0.3. 26 composed assets can reach it (in practice a Pendle router fill paid in one); not seen on any data. The read reproduces the writer, so the fix is in the writer's composition. - Pendle's native-ether consideration (#955 follow-up 3, unverified): the router may name native ether as
address(0)inSwapPtAndToken/MintPyFromToken, andderive/pendle.tspasses the event's token through as the consideration, which would leave a PT fill paid in ether unpriced. Check on chain first. - An identity rate is told by
rate === 1(#955 round 1): migration 116's writer drops a real rate of exactly 1; tell it by the leg's rate kind.
- A cross-book consideration in a composed asset is priced in its own book (#957 base defect 1).
The release's hand-run tools.
- An enrolment backfill run by hand buys Dune fetch-throughs under a soft cap (#954 follow-up 4): the guard stops only after the execution that crosses it (runs capped at 2 credits spent up to 2.6), and without
DUNE_MAX_CREDITS_PER_RUNa hand-runbackfill-portfolio-wallet.tsinherits the default of 50 per run. Set the cap for hand-runs. - The rate-facts backfill names the wrong reason in the reverse shape (#957 round 3 nit 1): where one leg is accepted at
chainand its siblings' series candidate is refused only because it conflicts with that fact, which the final pass then drops, the siblings are left asno candidate rate reproduces its stored value, which is false for them. Values are unaffected, but release step 5 has the operator record the rows left and why, so a false reason would be recorded. Report them assibling, or under a reason that names the two rates in one reading.
- An enrolment backfill run by hand buys Dune fetch-throughs under a soft cap (#954 follow-up 4): the guard stops only after the execution that crosses it (runs capped at 2 credits spent up to 2.6), and without
The fixture and the harness.
scripts/fixture/build.shpins no session time zone (#946 follow-up 1):seed.sql's day-stepped series land an hour off after a DST change on a machine not set to UTC. No /portfolio surface reads them, and the golden harness setsPGTZ=UTCitself. The fix isexport PGTZ=UTCinbuild.sh, which moves the schema hashtests/golden/manifest.jsonrecords (it hashesbuild.sh), so the goldens are regenerated with it.- 0xbbbb's matured PT and 0xaaaa's flat readings contradict their receipts (#955 follow-up 1, and its round-1 SF5): 0xbbbb's readings are held from the grid's start while its PT leg and its Aave debt move; 0xaaaa's are flat (180,000 USDC on an Aave leg with no index, a leg the Aave reader never writes, and 62,000 GHO) while 5 of its receipts move. Since S4a the audit books corrections on both and serves those stretches as not measured; making the readings follow the receipts moves the matured-PT e2e shapes, so it goes with a QA pass.
- No wallet-held listed fund with a chain-read NAV (#955 follow-up 2: Lido Earn, ether.fi Liquid, Treehouse). Adding one means the harness counts and answers that read.
- A failing
assert.okwith no message can spin tsx at 100% CPU instead of failing (#956 round 2 N1, measured onadapters/evidence/evidence.test.ts), and CI's 15-minutechecktimeout then names no cell. Give those calls a message. golden-portfolio.ts --walletswithout--outover the pinned fixture writes a partial tree overtests/golden, deleting the other wallets' goldens (#954 round 2 follow-up 2). Require--outfor a partial tree.- No golden reads a filtered activity together with
asoforcosts=1(#946 round 2 N3): the page sends both (ActivityFeed.tsx), and the captured statements hold the filter branch and the as-of branch but never both at once. One filtered set at one as-of day closes it. - The budgets' SF6 cell states 12 rows with one identity (#956 round 2 N3): its fresh rows (
atMs: NOW + i,budgets.test.ts) all round to one reading block, the shape the SF4 fixture note says cannot happen. Harmless (the cell pins words and counts only); space them 12 s apart. - The comparator's Fluid seam (#954 follow-up 7): real Fluid vaults are answered "no pool", so a real smart leg's advertised rate is a dash in OLD and NEW alike, never a difference. To compare the quoted smart-leg rate too, record the real vaults' pools once (they are immutable) and answer from that record.
The adapter contract (R10, #951's follow-ups). Route the snapshot's read order (
SNAPSHOT_READ_ORDER) andderive/compose.tsthroughVENUE_ADAPTERSinstead of their hand lists; givereadBalancea per-leg read (it reads the leg's whole venue and filters; the audit's strict read,adapters/strict-read.ts, is that read for five of the six since PR #961's final review round 2, and nothing in production callsreadBalanceany more); move the presentation layer's venue branches (ledger-v2-api.tsisPt/ptRedemptionFor/fundRedemptionDeclarationFor,quoted-rate-tables.ts,row-display.ts, the client'sisFluidSmartLeg) onto declarations (principalToken,role); renamepnl.ts's Fluid-named suites. CorrectPositionRead.decimals' comment (src/lib/portfolio/types.ts: "ERC-20 decimals ofaccountingAsset", which a bare PT's row contradicts: its reader stores the PT's own decimals there, rightly; #951's follow-up for S2). Give the R10 boundary guard a shrink-only list of whatv2/,ledger-v2-loader.tsandreconcile/booked.tsimport from outside the tree, beside the classifier allowlist: a venue rule moved into a new helper outsidev2/passes the guard today (#951 round 2 N2).Docs.
- The alert budgets' "the page names both causes" (#956 round 2 N2):
docs/data-pipeline.mdsays a reader-failure page names both causes (a reader that is down, an exit the ledger missed). Checked against S4a's wiring now that it has merged: the page is the hourly arm's[fail] ledger-auditline, whose remedy names both where the streak holds a "no leg" answer, and the[partial]line is only the audit job's reason and log line. What is left is that paragraph's account of the 6h checkpoint printing[partial]underrun-cron.shand exiting 2, which S4a replaced (the tick queues anauditjob; the worker runs it). Restate it. - Migration
114's header still calls the delete after a revert cosmetic: S4a's comment, which #960 review B1 corrected everywhere else (the release entry's rollback, §9, R13 anddocs/database.md). Everyscripts/sqlfile is in the fixture schema hashtests/golden/manifest.jsonrecords, so a comment edit there moves a golden: restate it with the next change that regenerates the goldens anyway (thePGTZ=UTCfix above, or a migration), keeping both runner tag strings out of the prose. docs/external-dependencies.md's environment table does not list the new optional knobs (INGEST_CONTINUOUS_BUDGET_MS,INGEST_CONTINUOUS_POPULATION_TTL_MS, theLEDGER_*_MAX_MINUTESbounds,LEDGER_WORKER_EXIT_WHEN_IDLE,GOLDEN_GATE_TIMEOUT_MS); each is documented where it is used (S7's own).
- The alert budgets' "the page names both causes" (#956 round 2 N2):
Ops: the ingester's and the worker's pm2 logs are outside the reference log rotation (#960 review round 2 N3):
scripts/ops/logrotate-onchain-credit.confmatches the four app logs only (docs/deployment.md#log-rotation). Addcreddit-event-ingester-*andcreddit-ledger-worker-*there, with the samecopytruncate, and install it on the box. The worker's is small at today's population; the ingester's is the one that kept the key (release step 10).For the integration PR's final review: the §C5 rendered test first runs there (#955 round 2): e2e is outside the sub-PRs, so §C5's "nothing in the band prints a value" test and the e2e constants S2a updated run for the first time in the integration PR's CI.
Staging: the scrub deletes the
wallet-tokenrollout marker (#954 follow-up 2). After every reseed staging treats the bare-token stream as not rolled out, so a registration replay there derives before its bare-token catch-up instead of waiting for it as prod does. The suggestedAND address <> '*'(with the scrub test) is a decision, not only a fix: staging runs no ingester, so with the marker kept every staging enrolment waits for an ingester hand-run, as it did in the S6 window.