Skip to content

docs(design): the as-of scoped share as the replica bootstrap - #799

Merged
scbrown merged 5 commits into
mainfrom
malcolm/s241-asof-share-design
Oct 25, 2026
Merged

scbrown merged 5 commits into
mainfrom
malcolm/s241-asof-share-design

Conversation

@scbrown

@scbrown scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

What

A design document, with no code: docs/design/as-of-share.md. It covers the bootstrap half of local-sync-mode.md §5. A follower starts from a cut of its source at transaction T, then tails /changes from T.

The design in brief

  • The cut is taken exactly like /acquire: one read transaction, one pinned T, complete or an error. But it emits change-feed records (typed values, lossless bytes, valid_from/valid_to) instead of N-Triples. N-Triples cannot carry valid time, so a replica bootstrapped from them could never equal a source the feed describes. With records, a follower uses one record-to-datum conversion for both bootstrap and tail.
  • Manifest: source {store_id, log_epoch}, watermark_tx, scope_digest, policy_digest, shapes_hash, and per-graph completeness.
  • Size: past the frame budget, the cut uses the change stream's existing begin/part/commit frames, not a second chunking scheme.
  • Frozen graphs: read as of T from their mounted packs. Freezing remaps term ids but not transaction ids, so a rebuilt follower equals its source. An unmounted or corrupt pack refuses the cut.
  • Epoch handoff: if a page names a different log_epoch, the follower stops, refuses to splice, and re-bootstraps.
  • Authorization: the route is write-class (fail-closed) until per-graph read authorization has its own design and ruling. No second principal can read through it.

Review asks

  • Records instead of N-Triples as the bootstrap payload.
  • Reading frozen packs as of T, relying on transaction ids being preserved. The implementation will test this explicitly.
  • Write-class gating as the interim authorization.

Rollback

Docs only: revert the file.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

Design only. It specifies the bootstrap half of local-sync-mode section 5.
- A cut taken like an acquisition (one read transaction, one pinned tx)
  that emits change-feed records with valid time and lossless bytes, so a
  follower needs one conversion for bootstrap and tail.
- A manifest with source identity and log epoch, watermark, scope and
  policy digests, shapes hash and per-graph completeness.
- The feed's chunk frames past the frame budget.
- Frozen graphs read from their mounted packs, so a rebuilt follower
  equals its source.
- An epoch handoff that refuses to splice.
Until per-graph read authorization has its own ruling, the route is
write-class and fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown and others added 2 commits October 24, 2026 02:24
A whole-board cut is one long read transaction, which pins the WAL. The
read now spools to disk and closes its transaction before anything is
sent, so a slow follower never holds the source's snapshot. The doc also
states the targets (wall time and RSS at production size, measured on
a scratch copy in the source PR) and the time and WAL-growth ceilings
that abort a cut, which is refused and never sent partially. Only one
read phase runs per source at a time. The route checklist (endpoint
lists, share completeness, REST reference) is now named too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
Pre-commit failed on 13 MD032 blanks-around-lists findings. Whitespace
only: one blank line before each list; no wording changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGES at eb116353dcbcc75183dc57e6d2155cef043f89f3 after complete-green CI and the full design read.

  1. Scope digest (lines 84–85): concatenating sorted scope IRIs and sorted context IRIs without a role boundary is ambiguous. Scope [a] + context [b,c] encodes identically to scope [a,b] + context [c] for valid IRIs. Use separately tagged/length-delimited arrays (e.g. canonical JSON), with boundary-shift and ordering controls. The manifest hash can differ while this scope binding still collides.
  2. Deadline (lines 149–163): checking only every 10,000 records misses an entire cut below that count, including expiry during finalization. State the elapsed-time cancellation/interrupt bound and final time/WAL check before publishing a receipt; test short/empty cuts, stalled reads and late expiry, with cleanup and no frame sent.
  3. Spool quota (lines 130–143): refusing an oversized cut before reading requires a conservative size bound/reservation. Define that allowance plus an incremental pre-write cap and cleanup/ENOSPC behavior; account for cached, active and sorting scratch. An unknown-size cut must not fill the disk before its final size is known.

Five independent private contract-model controls pass, including the collision, short-cut checkpoint gap and quota example, with a clean HOME guard. These are design counterexamples, not a claim about an implemented endpoint or production behavior. No runtime operations performed. Native prepared content e466995729c407934c5be77ce1db1084cffdabf633a3cacad83f227892d39e2c remains unapproved. Delta-only SAME re-review after repair and fresh complete-green CI.

grant's three findings (CHANGES at eb11635):

1. scope_digest is SHA-256 over tagged compact JSON
   {"context_graphs","scope"}. Moving an IRI between the lists changes it.
2. The ceilings also run inside SQLite every 1,000 VM steps, so one slow
   statement is interrupted. A final check after the spool is flushed and
   before any manifest exists covers short and empty cuts.
3. The disk allowance is concrete:
     min(quota - bytes already under the spool dir, free disk - 1 GiB)
   It is fixed before the transaction opens. Every part and sorted run is
   charged before it is written, and ENOSPC aborts as disk_full, with
   cleanup.

Also aligned with the frame contract wu pinned on the item:
- the single quipu.asof frame and the quipu.asof.begin/part/commit names;
- records carry tx, timestamp, actor and source;
- "ROOT" can be cut when named;
- per-graph digest = the applier's share_graph_digest, computed by
  external sort.

Serving a re-request from its spool is marked as a later optimization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Repair for CHANGES at eb116353. The new head is 17a2ab70 (one commit, docs only). Each finding is answered in the text, and the s24.1 source implements and tests it:

  1. Scope digest: SHA-256 over the compact JSON {"context_graphs": [...], "scope": [...]}, each list sorted. Controls: your boundary-shift case ([a]+[b,c] vs [a,b]+[c]) and a list swap differ, and reordering a list does not change the digest. A sabotage back to plain concatenation turns the control red.

  2. Deadline: time and WAL ceilings run in three places:

    • inside SQLite as the progress handler, every 1,000 VM steps, so one slow statement is interrupted mid-step;
    • every 10,000 records;
    • a final check after the spool is flushed and before any manifest exists.

    Tests cover a single slow statement (interrupted in well under 1 s), an empty cut past its ceiling, and short cuts. Each ends aborted, with no spool and no frame. Sabotages of the handler and of the final check each turn a test red.

  3. Allowance: min(quota - every byte under the spool directory, free disk - 1 GiB floor), counted recursively, so it includes spools being sent, digest runs and a partial spool. It is fixed before the read transaction opens, under the one-read-phase lock. With no allowance the cut is refused and never reads. Every part and sorted run is charged before it is written, and passing the allowance aborts as spool_quota. ENOSPC aborts as disk_full. Both clean up.

Also changed: the text now matches the frame contract pinned on the item:

  • the single quipu.asof frame, and the quipu.asof.begin/part/commit names;
  • records carry tx, timestamp, actor and source;
  • "ROOT" can be cut when named;
  • the per-graph digest is the applier's share_graph_digest, computed by external sort.

Serving a re-request from its spool is now marked as a later optimization: the source deletes the spool after sending.

Fresh CI is running on 17a2ab70. No rerun or waiver.

@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Budget measured: the read-phase target is MISSED. This is a design finding for §Budget, posted here because it affects the targets this PR states.

Setup:

  • a release build of the source implementation;
  • a scratch copy of a production-sized store (9.46M current facts), never production;
  • the cut is ROOT plus 38 committed graphs = 9,159,653 records (96.8% of current facts).
run page cache read phase per record CPU peak RSS growth spool
1 partly cold 611.6 s 66.8 µs 23% 239 MiB 3.85 GiB
2 warm 163.5 s 17.9 µs 64% 239 MiB 3.85 GiB
  • Read phase ≤ 120 s: missed in both runs.
  • RSS ≤ 256 MiB: met, with 7% headroom.
  • As written, a cold whole-board cut passes the 300 s time ceiling and always aborts.

Cause (inferred from 23% CPU while cold): random I/O. The scan walks (g, e, a, v) index order with one table lookup per row, plus one transaction lookup per record.

Proposed fix, to be measured before it goes into this design:

  • one sequential pass over the fact table in row order for all selected graphs;
  • the per-graph digest is order-independent (sorted), so emission order can change freely;
  • transaction metadata comes from a bounded cache.

If that still misses, the alternative is to re-rule the targets, for example ≤ 120 s warm with a read ceiling above the measured cold time.

@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGES — Quipu #799 SAME repair review, exact 17a2ab7, aegis-hf1. Grant remains the sole independent nonauthor reviewer; Malcolm author. Rechecked all 21 hosted checks COMPLETED/SUCCESS. Current native classification d4b4e53aacd1d21aff27b263ccaa45d0228def21c262358ac35258cbfcf24073 and PREPARED 7eeaea94995d8ecf83c33fdd1c7bae2a26b091191452319e190b1a4458ace1d1 accepted:false preceded repair-source inspection. Own isolated clean detached checkout. Reviewed only the one-file repair against the retained full review, not a second broad audit.

Resolved R1: the scope digest is now a compact canonical object with separately tagged context_graphs and scope arrays, each sorted. Moving an IRI across roles changes the encoded input; reordering within a role does not. Resolved R3: pre-transaction allowance counts all existing spool-directory bytes, including active sends, partial spools and digest runs, respects the free-space floor, and precharges each part/run; spool_quota and disk_full abort and clean up. Cached re-request service is explicitly deferred.

R2 remains P1, narrowed to non-SQLite work while the transaction is open: docs/design/as-of-share.md lines 92-97 introduce bounded-size external sort runs and a merge into the hash; lines 129-131 close the read transaction only after the digests are final. Lines 163-173 check SQLite VM steps, each 10000 records, and after the final flush. SQLite progress callbacks do not execute during an external sort/merge/digest operation. Therefore a cut can finish its last SQLite read at t=1, spend another 301 seconds in non-SQLite digest work while retaining the read transaction, and only then reach the final check. That check correctly prevents publication but cannot undo the already exceeded WAL-pinning window. This is a static contract counterexample, not a native endpoint reproduction or measured claim that sorting normally takes 301 seconds.

Require an explicit bound/cancellation/check schedule for non-SQLite work while the read transaction remains open, including sorting/merging/finalization, or close the snapshot transaction after raw snapshot rows have been safely spooled and do ordering/digests afterward. Add a controlled late non-SQLite-finalization case: no frames, scratch cleanup and a bounded read-transaction lifetime. Do not describe a SQLite progress handler as interrupting an arbitrary stalled I/O operation. No demand for impossible hard cancellation of an uninterruptible kernel operation; state that limitation honestly.

Seven deterministic private contract controls PASS, covering role separation/order invariance, quota precharge, SQLite-check and empty-cut positives, the non-SQLite lifetime counterexample, and an off-transaction-digest alternative. These models test the design argument, not production code. Command exit0; HOME guard final CLEAN for 157 watched paths and 5 UID resources. No proposal edits, build, live export, runtime change or approval. Author consultation delivered live as aegis-wcil2k3wb.

Retain original full review and repair history. Native record remains accepted:false; signed CHANGES names the new prepared content and full head. Resume only on Malcolm same-lineage repair plus complete-green CI and current native preparation, then Grant reviews that delta. Tim retains normal landing/watch; fzv/profile/runtime holds remain unchanged.

…w R2)

grant's R2 on 17a2ab7: merging digest runs ran with the read transaction
still open, so slow non-SQLite work could pin the WAL past the ceiling.

Now the transaction closes as soon as the scan ends and passes its final
ceiling check. Merging the runs and writing the last part hold no
snapshot. Inside the read, a spill is bounded to one run (32 MiB, shared
by all graphs), and the ceilings are checked after every spill. The
verification plan adds the checkpoint test (completes after the scan,
blocked during it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

R2 repair: head 87269fee (one docs commit since 17a2ab70). You were right: merging the digest runs ran with the read transaction still open.

  • Now: the transaction closes as soon as the scan ends and passes its final ceiling check. Merging the digest runs and writing the last part happen after that and hold no snapshot.
  • Inside the read, the only non-SQLite work beyond per-record serialization is spilling one sorted run. That is bounded to 32 MiB (shared by all graphs), and the ceilings are checked after every spill.
  • Test in the source PR (feat(asof-share): cut a replica bootstrap at one transaction #805, local 4491a261):
    • a TRUNCATE checkpoint from another connection completes at the moment the read has closed (busy = 0);
    • control: the same checkpoint during the scan is blocked (busy = 1);
    • sabotage: removing the early close turns the test red.

scbrown added a commit that referenced this pull request Oct 24, 2026
…erging

One instrumented cold release run on the production-sized scratch copy
(23.8 us/record): frame 38.4%, sql_step 20.5%, serialize 18.2%, terms 11.4%.
perf is unavailable (perf_event_paranoid 4), so the cut times its own
phases. Fixing the top two:

- frame: every record was serialized three times (digest line, size
  check, part text). PartBuilder now holds serialized lines and writes the
  part text directly, byte-identical to the serde_json form. A test keeps
  the old serde_json construction as the oracle.
- serialize: the record is written from an AssertRecord struct whose
  fields are in alphabetical order (the canonical line, with no map per
  record), and the one line feeds both the digest and the part.

Also, from grant's #799 point R2: the read transaction now closes right
after the scan and its final ceiling check. Merging digest runs and
writing the last part happen afterwards and never pin the WAL. Ceilings
are checked after every spill. A test proves a TRUNCATE checkpoint from
another connection completes at that point, and is blocked while reading.

Sabotages (a part key renamed, record fields reordered, no early close)
each turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown added a commit that referenced this pull request Oct 24, 2026
…ance

From grant's review of the design (#799):

- scope_digest is SHA-256 over compact JSON {"context_graphs","scope"},
  each list sorted. Moving an IRI between the lists changes the digest;
  reordering a list does not.
- The final time/WAL check runs after the spool is flushed and before any
  manifest exists, so a cut of any size, including an empty one, ends
  inside its ceilings or is aborted with no spool left.
- The disk allowance is fixed before the transaction opens:
    min(quota - everything already under the spool directory,
        free disk - disk_floor_bytes (1 GiB))
  Nested run directories are counted. No allowance refuses the cut
  without reading. Parts and sorted runs are charged before they are
  written. A write that hits ENOSPC aborts as disk_full, with cleanup.

Sabotages of each (a concatenated digest, no final check, a flat usage
count, no disk floor) turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown added a commit that referenced this pull request Oct 24, 2026
…erging

One instrumented cold release run on the production-sized scratch copy
(23.8 us/record): frame 38.4%, sql_step 20.5%, serialize 18.2%, terms 11.4%.
perf is unavailable (perf_event_paranoid 4), so the cut times its own
phases. Fixing the top two:

- frame: every record was serialized three times (digest line, size
  check, part text). PartBuilder now holds serialized lines and writes the
  part text directly, byte-identical to the serde_json form. A test keeps
  the old serde_json construction as the oracle.
- serialize: the record is written from an AssertRecord struct whose
  fields are in alphabetical order (the canonical line, with no map per
  record), and the one line feeds both the digest and the part.

Also, from grant's #799 point R2: the read transaction now closes right
after the scan and its final ceiling check. Merging digest runs and
writing the last part happen afterwards and never pin the WAL. Ceilings
are checked after every spill. A test proves a TRUNCATE checkpoint from
another connection completes at that point, and is blocked while reading.

Sabotages (a part key renamed, record fields reordered, no early close)
each turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown added a commit that referenced this pull request Oct 24, 2026
…ance

From grant's review of the design (#799):

- scope_digest is SHA-256 over compact JSON {"context_graphs","scope"},
  each list sorted. Moving an IRI between the lists changes the digest;
  reordering a list does not.
- The final time/WAL check runs after the spool is flushed and before any
  manifest exists, so a cut of any size, including an empty one, ends
  inside its ceilings or is aborted with no spool left.
- The disk allowance is fixed before the transaction opens:
    min(quota - everything already under the spool directory,
        free disk - disk_floor_bytes (1 GiB))
  Nested run directories are counted. No allowance refuses the cut
  without reading. Parts and sorted runs are charged before they are
  written. A write that hits ENOSPC aborts as disk_full, with cleanup.

Sabotages of each (a concatenated digest, no final check, a flat usage
count, no disk floor) turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown added a commit that referenced this pull request Oct 24, 2026
…erging

One instrumented cold release run on the production-sized scratch copy
(23.8 us/record): frame 38.4%, sql_step 20.5%, serialize 18.2%, terms 11.4%.
perf is unavailable (perf_event_paranoid 4), so the cut times its own
phases. Fixing the top two:

- frame: every record was serialized three times (digest line, size
  check, part text). PartBuilder now holds serialized lines and writes the
  part text directly, byte-identical to the serde_json form. A test keeps
  the old serde_json construction as the oracle.
- serialize: the record is written from an AssertRecord struct whose
  fields are in alphabetical order (the canonical line, with no map per
  record), and the one line feeds both the digest and the part.

Also, from grant's #799 point R2: the read transaction now closes right
after the scan and its final ceiling check. Merging digest runs and
writing the last part happen afterwards and never pin the WAL. Ceilings
are checked after every spill. A test proves a TRUNCATE checkpoint from
another connection completes at that point, and is blocked while reading.

Sabotages (a part key renamed, record fields reordered, no early close)
each turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF
scbrown added a commit that referenced this pull request Oct 24, 2026
* fix(freeze): mint a new log epoch when freeze or thaw changes served history

Deep freeze relocates a graph's rows into a pack. The graph reads the same,
so a following replica needs no record, but the change feed no longer serves
those rows: a replay from an older cursor silently returns fewer records
(local-sync-mode.md F2). Thaw re-imports the rows behind every cursor.

Both now mint a new log epoch inside their savepoint, so a follower sees the
epoch change and re-bootstraps instead of trusting its cursor. Freeze emits
no tombstone records: that would make a replica drop facts the source still
serves.

The test checks that the window graph's feed records go from present to
absent and that the epoch changes on freeze and again on thaw. Removing
either mint turns it red.

Refs aegis-xdvg.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185RxTpLsPCQFB9ni72Nzac

* fix(freeze): keep the log epoch when a freeze or thaw moves no rows

A freeze that removed no feed rows, or a thaw that restored none,
changed no served history. Minting a new epoch then would make every
follower re-bootstrap for nothing. A refused freeze already rolled back
before the mint, and a test now pins that too.

The freeze test also checks that the next change page names the new
epoch, which is how a follower learns its cursor belongs to the old
history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* refactor(stream): build oversized-transaction parts one record at a time

Move the F6 part builder into the library as feed_parts::PartBuilder.
It hands out each part as it closes, so a caller can write parts to disk
as they are made, and memory holds one part rather than the whole
transaction. chunk_transaction now drives it. Part layout, sizes and the
digest chain are byte-identical; the stream tests pass unchanged.

This lets the as-of share send a cut with the same frames and digests
instead of a second chunking scheme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* feat(asof-share): cut a replica bootstrap at one transaction

asof_share::cut takes the live facts of 1..=64 committed graphs at
transaction T, inside one read transaction, and writes them as /changes
assert records into an on-disk spool of feed parts. The manifest binds
source store and log epoch, watermark_tx, scope, policy and shapes
digests, and per-graph record counts and digests.

The read transaction is open only while spooling. Wall time and WAL growth
are checked every 10,000 records, and either ceiling aborts the cut and
deletes the spool. A full spool directory refuses the cut before the
transaction opens. The scan rides idx_geav, so it needs no sort.

Graphs served from an attached or frozen pack are refused until the
frozen-pack read lands (s24.1.1).

Conformance tests bootstrap a replica from the cut, tail /changes from T,
and compare live-fact digests with the source. They cover: a retract after
T, a write committed during the cut, a pinned T with a later assert,
splitting, the ceilings and the refusals. Four sabotages (no retract bound,
no tx bound, raw valid_to, no WAL check) each turn the suite red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* feat(asof-share): POST /share/asof streams a cut from its spool

The handler cuts under read admission, then streams the spool as
quipu.asof.begin (manifest, totals, digest, source), numbered
quipu.asof.part frames and a quipu.asof.commit barrier. Only the
barrier carries an SSE id. Every refusal and abort is answered before
the stream starts. A cut that cannot be finished never sends a barrier.

Admission:
- one read phase per source; a second request gets 503 cut_in_progress
  with Retry-After and is not queued, so cuts never extend the pinned
  window back to back;
- a send slot from the change stream's pool is taken first;
- the spool is deleted when the stream ends, and spools older than an
  hour are swept.

The route is write-class: it writes nothing, but this keeps it
fail-closed until per-graph read authorization has its own ruling.
docs: REST reference entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* feat(asof-share): meet the pinned frame contract and bound every stall

Frame contract (wu, aegis-s24.1; consumer quipu #803):
- a cut that fits one frame is a single quipu.asof {manifest, records};
- larger cuts keep F6's begin/part/commit chain;
- records carry their transaction's timestamp, actor and source, as
  /changes records do;
- "ROOT" can be cut when named;
- the per-graph digest is the applier's share_graph_digest: SHA-256 over
  the canonical record lines, sorted, joined by "\n". The producer gets
  there by sorting runs of at most 32 MiB, spilling them beside the spool
  and merging, so memory stays bounded. A test checks it against the
  applier's function, verbatim, at three run sizes.

Ceilings (grant's review of the design):
- The time and WAL ceilings also run as SQLite's progress handler during
  the read phase, so one slow step is interrupted mid-statement instead of
  waiting for the next 10,000-record check.
- Spilled digest runs count against the spool quota as they are written.

Sabotages: a handler that never stops, unsorted spilled runs and an
unsorted in-memory sort each turn a test red.

examples/asof_budget measures a cut on a scratch copy against the design
budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* fix(asof-share): tagged scope digest, final ceiling check, disk allowance

From grant's review of the design (#799):

- scope_digest is SHA-256 over compact JSON {"context_graphs","scope"},
  each list sorted. Moving an IRI between the lists changes the digest;
  reordering a list does not.
- The final time/WAL check runs after the spool is flushed and before any
  manifest exists, so a cut of any size, including an empty one, ends
  inside its ceilings or is aborted with no spool left.
- The disk allowance is fixed before the transaction opens:
    min(quota - everything already under the spool directory,
        free disk - disk_floor_bytes (1 GiB))
  Nested run directories are counted. No allowance refuses the cut
  without reading. Parts and sorted runs are charged before they are
  written. A write that hits ENOSPC aborts as disk_full, with cleanup.

Sabotages of each (a concatenated digest, no final check, a flat usage
count, no disk floor) turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* docs(asof-share): REST reference for the pinned frames; refresh witnesses

The REST entry now covers:
- the single quipu.asof frame;
- "ROOT" when named;
- record transaction fields;
- the per-graph and scope digest definitions;
- disk_full;
- the disk allowance.

rest-api.md and src/lib.rs changed, so their recorded source_sha256
witnesses are refreshed. Their summaries still hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* fix(asof-share): clippy then_some; budget harness cuts ROOT too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* perf(asof-share): one sequential rowid-order pass instead of index-order lookups

The full-board budget missed its read target (611.6 s partly cold, 163.5 s
warm, against 120 s). The cause is inferred from 23% CPU while cold:
random I/O. The scan walked idx_geav (g, e, a, v) with one table lookup
per row, plus one transactions lookup per record.

The cut is now one pass over facts in rowid order (NOT INDEXED) for every
selected graph at once:
- the read is sequential;
- consecutive rows share a transaction, so its metadata is read once per
  transaction;
- the per-graph digest sorts its lines, so emission order does not reach
  it;
- every graph's digest run shares one memory budget, and the largest run
  spills first.

Tests: the plan is exactly "SCAN facts". Each record carries its own
transaction's time. Sabotages (a stale transaction cache, unsorted
spilled runs) each turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* fix(asof-share): name the transaction metadata tuple for clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* perf(asof-share): serialize each record once; close the read before merging

One instrumented cold release run on the production-sized scratch copy
(23.8 us/record): frame 38.4%, sql_step 20.5%, serialize 18.2%, terms 11.4%.
perf is unavailable (perf_event_paranoid 4), so the cut times its own
phases. Fixing the top two:

- frame: every record was serialized three times (digest line, size
  check, part text). PartBuilder now holds serialized lines and writes the
  part text directly, byte-identical to the serde_json form. A test keeps
  the old serde_json construction as the oracle.
- serialize: the record is written from an AssertRecord struct whose
  fields are in alphabetical order (the canonical line, with no map per
  record), and the one line feeds both the digest and the part.

Also, from grant's #799 point R2: the read transaction now closes right
after the scan and its final ceiling check. Merging digest runs and
writing the last part happen afterwards and never pin the WAL. Ceilings
are checked after every spill. A test proves a TRUNCATE checkpoint from
another connection completes at that point, and is blocked while reading.

Sabotages (a part key renamed, record fields reordered, no early close)
each turn a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* docs(asof-share): describe the graph digest as it is computed

GraphCompleteness.digest is the applier's share_graph_digest (sorted lines
joined by a newline), not lines in send order; and digest runs share one
memory budget across graphs. The budget harness also reports read plus
digest merge, the baselines' boundary. (wu, s24.2 seam check.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* fix(asof-share): refuse a frozen graph instead of cutting it empty

The refusal checked only attached_graphs, which lists graphs CONTRIBUTED
by an attachment (registry source = alias). A frozen graph stays a local
graph whose rows moved into its own pack, so it was not on that list.
The cut read main for it and emitted it with zero records and no error:
the silent-empty-graph failure the design forbids. This was found by
writing the frozen-graph test first (s24.1.1): the cut returned Ok.

is_frozen checks the frozen-pack registry (mounted, not thawed). The test
freezes a graph and requires a refusal, both in-session and after
reopening. Sabotage (drop the check) turns it red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* refactor(asof-share): split under the file-size ratchet

CI's file-size ratchet failed on #805: src/asof_share.rs had 706 code
lines and src/server.rs 503, against a limit of 500.

- asof_share/manifest.rs: the manifest types, re-exported unchanged.
- asof_share/support.rs: the record form, the read-phase guard and
  ceilings, disk accounting, the graph digest and the shapes hash.
- The asof handler module and its route move into publication.rs, beside
  /acquire, so server.rs is back at its 500-line baseline. The handler
  uses crate:: paths, and server/asof.rs leaves the route-free list.

Code moves only; no behaviour change. asof_share.rs is 450 code lines,
server.rs 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

* fix(asof-share): give each cut its own spool

Review on 5c86758: two cuts of the same source at the same T share a
manifest, and both were renamed to <manifest>.parts. Dropping the first
response deleted that path, so the second, already-complete cut emitted
quipu.error instead of its commit barrier.

The spool is now <manifest>-<pid>-<n>.parts, owned by one response for
its whole life. The manifest digest is unchanged.

identical_cuts_own_their_spools abandons, then completes, the first of
two identical cuts while the second still has to open and read its
spool, with a control that both share the manifest. Restoring the shared
name turns it red at the commit-barrier assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@scbrown
scbrown merged commit 07f1a77 into main Oct 25, 2026
21 checks passed
@scbrown
scbrown deleted the malcolm/s241-asof-share-design branch October 25, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant