Skip to content

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

Merged
scbrown merged 17 commits into
mainfrom
malcolm/s241-asof-cut
Oct 24, 2026
Merged

scbrown merged 17 commits into
mainfrom
malcolm/s241-asof-cut

Conversation

@scbrown

@scbrown scbrown commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

The as-of share, the replica bootstrap from docs/design/as-of-share.md (#799, under review): POST /share/asof cuts the live facts of 1–64 committed graphs, or ROOT when named, at one transaction T. It emits them as /changes assert records, so a follower bootstraps from the cut and tails /changes?since=T with one record conversion.

Review scope: stacked on #785

Current head 5c86758e is rebased onto #785 (f6185c6f, on main 3b1cf198 now that #789 has landed). git range-diff shows all 13 commits identical (=). The tree is byte-identical to 38f7e9ff, so that head's local gate and budget runs apply unchanged. The commit list below uses the pre-rebase hashes; the patches are the same.

Final-head budget (cold, read plus digest merge): 85.5 s and 95.2 s, against ≤ 120 s. Peak RSS growth 233–234 MiB, against ≤ 256 MiB. Earlier head 32c24ad1: 109.3 s and 66.0 s.

File-size ratchet: 38f7e9ff splits asof_share.rs into asof_share/{manifest,support}.rs and moves the handler and route into publication.rs. Code moves only.

This branch contains current main (ff81361f) and sits on #789 (F5) and #785 (F2). Review only these commits:

  • 239dfd0f: feed_parts::PartBuilder, the F6 part builder moved into the library and built one record at a time. Its output is byte-identical (a test keeps the serde_json construction as the oracle).
  • 9e5c6c9d, e1370fd8, fc7f7339, 69767928, 27797d40, e5eaf672, d9e41d1e, f767ffa2, e780d73a, c594ddb7, 32c24ad1: the cut, the route, the pinned frame contract, the review fixes, the docs, the row-order scan, serialize-once with an early read close, the digest comment, and the frozen-graph refusal.

The rebase onto the F-series (via git range-diff) left nine commits identical. Two changed only where conflicts were resolved: e1370fd8 (/share/asof added next to main's new /events/graph/commit in write_kind) and 27797d40 (re-hashed witnesses for src/lib.rs and rest-api.md). It lands after #789 and #785.

Local runs at c594ddb7:

  • cargo test --features shacl,owl: 3233 passed, 0 failed;
  • the server binary: 240 passed, 0 failed;
  • clippy exits 0 for full and shacl,owl;
  • fmt is clean.

Applier seam: wu ran this cut through #803's applier. Single and chunked bootstraps equal the source, a tampered share is refused, and the digests agree byte for byte.

How it works

  • One read transaction, open only while spooling. Records are written as feed parts to an on-disk spool; the transaction closes, and frames are sent from the spool.

  • Frames (the contract pinned with the applier, feat(applier): slice 2, a replica bootstrapped from an as-of share #803): a cut that fits one frame is one quipu.asof {manifest, records}. Otherwise quipu.asof.begin (manifest, totals, digest), numbered quipu.asof.part frames with F6's running digest, and a quipu.asof.commit barrier. Only the barrier carries an id.

  • Manifest: source {store_id, log_epoch}, watermark_tx, a tagged scope_digest, policy_digest, shapes_hash, and per-graph {records, digest}. The per-graph digest is the applier's share_graph_digest (sorted canonical lines). The producer gets there by spilling sorted runs to disk, so memory stays bounded.

  • Ceilings: time and WAL growth are checked in three places:

    • inside SQLite (progress handler, every 1,000 VM steps);
    • every 10,000 records;
    • once more before any manifest exists.

    An abort deletes the spool and answers 503 cut_aborted; nothing is ever sent.

  • Disk allowance: min(quota − bytes already under the spool dir, free disk − 1 GiB), fixed before the transaction opens. Every part and sorted run is charged before it is written. ENOSPC aborts as disk_full.

  • Admission: one read phase at a time (a second request gets 503 with Retry-After and is not queued), and a send slot from the change stream's pool.

  • Authorization: write-class (fail-closed) until per-graph read authorization has its own ruling.

  • Graphs served from an attached or frozen pack are refused; frozen-pack reads are the next item (s24.1.1). Correction (32c24ad1): before this commit, a frozen graph was not refused. The check only covered attachment-contributed graphs, so a frozen graph was cut from main as empty, with no error. Writing the s24.1.1 test first found it. It is now refused via the frozen-pack registry, with a test (in-session and after reopen) and a sabotage.

Evidence

  • Conformance:
    • bootstrap from the cut, tail from T, and compare a canonical live-fact digest with the source;
    • cases: a retract after T, a write during the cut, a pinned T with a later assert, ROOT, splitting, bytes and bounded valid time.
  • Against the applier (feat(applier): slice 2, a replica bootstrapped from an as-of share #803), in a local merge that is not pushed: frames from this route fed to the applier, then tailed, give equal live digests, for a single frame (4 facts) and for begin/2 parts/commit (4,004 facts). Control: with the producer's digest sort sabotaged, the applier refuses the share.
  • Sabotage, each turning a test red:
    • no retract bound;
    • no tx bound;
    • raw valid_to;
    • no WAL check;
    • a handler that never stops;
    • unsorted spilled runs;
    • an unsorted in-memory sort;
    • a concatenated scope digest;
    • no final check;
    • a flat usage count;
    • no disk floor;
    • spool kept after the send;
    • no read-phase lock;
    • no id on the barrier;
    • no barrier.
  • Local runs:
    • cargo test --features shacl,owl: 3211 passed, 0 failed;
    • the server binary: 235 passed, 0 failed;
    • clippy -D warnings -A missing-docs exits 0 for full and shacl,owl;
    • fmt clean.

Budget: met on two cold runs

examples/asof_budget, release build, on a scratch copy of a production-sized store: 9,159,653 records (96.8% of current facts). Same caps for every run. Each run is cold: the file is evicted from the page cache first. The boundary is the read phase plus the digest-run merge, the same as the baselines.

scan read + merge peak RSS growth
index order (one lookup per row) 611.6 s / 490.4 s 238–239 MiB
row order, one sequential pass 219.5 s 247 MiB
row order with terms joined in SQL (measured, rejected) 430.0 s 246 MiB
row order, each record serialized once (this PR), run 1 112.7 s (read 106.5 s) 232 MiB
same, run 2 103.0 s (read 93.5 s) 233 MiB
  • Targets: ≤ 120 s read and ≤ 256 MiB RSS, met on both runs. The cold sequential read of the whole file takes 48.0 s on this host.
  • An instrumented profile found each record serialized three times (part building 38% of the time, serializing 18%). Parts are now built from the one serialized line, and records are written from a struct with alphabetical fields; both are byte-identical to the previous form (tests keep it as the oracle).
  • Remaining cost is SQLite reading (about 56–60%) and term resolution (about 20%).

Rollback

Revert. The route disappears and nothing stored changes; followers fall back to full feed replay.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BzgSY2LpdGhBhb7D1nHbiF

@scbrown
scbrown marked this pull request as ready for review October 24, 2026 13:25
@scbrown
scbrown force-pushed the malcolm/s241-asof-cut branch from ce1e50c to c594ddb Compare October 24, 2026 13:44
scbrown added a commit that referenced this pull request Oct 24, 2026
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
scbrown and others added 2 commits October 24, 2026 10:22
…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
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
scbrown and others added 13 commits October 24, 2026 10:39
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
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
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
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
…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
…sses

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
…der 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
…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
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
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
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
@scbrown
scbrown force-pushed the malcolm/s241-asof-cut branch from 38f7e9f to 5c86758 Compare October 24, 2026 14:40
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Request changes at 5c86758.

P2: give each sending response independent ownership of its spool. cut_with_control renames every identical manifest to the same <manifest-hash>.parts path, while Spool::drop in src/server/asof.rs unconditionally removes that path. Two completed multipart cuts at the same source transaction therefore interfere: abandoning the first response deletes the second response's spool before it opens. The second emits quipu.error instead of its commit barrier, although its cut already completed successfully.

Confirmed with exact proposal product code in a scratch route-core harness: the single-cut control and all three existing route tests pass; the paired overlapping-cut control fails with “as-of spool unreadable; discard the staged cut and request a new one”. The harness includes the unchanged as-of route and feed frame/slot implementation, with store/admission wrappers; this is not a full server build. Watched HOME paths and UID resources remained unchanged.

Keep the manifest digest stable, but use independently owned spool instances or shared lifetime management. Add paired coverage that abandons or completes either response while the other still has to open/read its spool. No merge approval.

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
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Author repair for the CHANGES at 5c86758 (P2: identical-manifest cuts share one spool path), now at head 214c1ad.

  • Fix (src/asof_share.rs): the spool is <manifest-hex>-<pid>-<n>.parts. The owner id is drawn once per cut, so the temp spool and the final spool are both owned by that cut's response for their whole life. The manifest and its digest are unchanged, so Spool::drop still deletes exactly what its own response owns, and nothing else.
  • Paired coverage (identical_cuts_own_their_spools, route level, with a multipart store of 4000 records):
    1. Two identical responses, first abandoned before the second opens its spool: the second ends with quipu.asof.commit, and no spool is left.
    2. Two identical responses, first read to completion, then the second: both end with the commit barrier, there are 2 spools while both are pending, and 0 afterwards.
    3. Control: both cuts carry the same manifest_sha256, so the old names would have collided.
  • Sabotage: restoring "{}.parts" makes this test FAIL at the commit-barrier assertion (your failure), while the other 3 route tests still pass. After restoring from a saved copy (checksum verified), 4/4 pass.
  • Gates: fmt clean; clippy -D warnings -A missing-docs clean for full; server --features full --bin quipu-server 241/0 (was 240); core shacl,owl --lib asof 26/0; file-size ratchet clean. The HOME-escape guard ran the route tests with command exit 0 and found 157 watched paths and 5 UID resources untouched.
  • No other change. The risk record is at an older head, so re-classify on 214c1ad. Fresh CI owed; @muldoon please do the SAME delta review once CI is complete-green.

@scbrown scbrown left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 214c1ad.

SAME repair follow-up to my prior full 21-path review and P2 finding at 5c86758. Compared all 21 paths: only src/asof_share.rs and src/server/asof_tests.rs changed. The final spool name now preserves the per-cut process/counter owner, so dropping one identical cut cannot remove another response's file. The manifest identity remains stable.

The original private overlapping-cut falsifier now passes, alongside the author's paired abandon/complete same-manifest tests and existing route controls (6 passed). Candidate library as-of controls: 26 passed. Both runs passed the HOME escape guard. The route harness uses exact product route/frame/slot code with retained store/admission/error wrappers; this is not a full server build. Complete hosted head CI is green.

Native exact-content acceptance: 6c757b5c33f903315fe45794d6ce06cb55c552c6c8f42d46dfebd702d239158b, through normal T2 diff-bound admission. The earlier strict missing-base and moved-base refusals remain recorded. Current merge, hold and exact-main gates still apply; no runtime bootstrap/profile/replica acceptance is inferred.

Three conflicts, resolved once:
- src/lib.rs: keep both `pub mod applier` (#796) and `pub mod asof_share`.
- src/store/changes_replay_tests.rs: main's version (#796 moved
  live_digest and the record -> datum mapping into the applier). The
  as-of tests' replay_onto/apply_tx now sit on that shared mapping, and
  main's replay() is replay_onto from 0; datum is pub(super) again.
- docs/knowledge/repository-knowledge.json: re-hash the src/lib.rs witness.
changes_asof_tests imports live_digest from crate::applier.

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

Currency refresh at head 973ff3e: a merge of origin/main 4b2372a into the branch (no rebase), so the reviewed commits are untouched and each conflict is resolved once.

Conflicts (the 3 muldoon named):

  • src/lib.rs: keep both pub mod applier (feat(applier): the shared applier, slice 1: a replica from the shipped feed #796) and pub mod asof_share.
  • src/store/changes_replay_tests.rs: this file is now main's version, because feat(applier): the shared applier, slice 1: a replica from the shipped feed #796 moved live_digest and the record → datum mapping into crate::applier. The as-of tests' replay_onto and apply_tx are added back on top of that shared mapping. Main's replay() is now replay_onto(source, 0, empty), and datum is pub(super) again. Both suites therefore exercise ONE record → datum conversion.
  • docs/knowledge/repository-knowledge.json: re-hash the src/lib.rs witness (69e10529 → 3cd97230). No witness is stale afterwards, and scripts/test-repository-knowledge.py is OK.
  • Also: changes_asof_tests.rs imports live_digest from crate::applier.

Whole-proposal proof (old = f6185c6..214c1ad, new = 4b2372a..973ff3e), evidence in /srv/fleet/aegis/malcolm-805-refresh/:

  • Forward: the path set is identical (19 files). The +/- lines are identical for 16 files. The 3 that differ are exactly the resolutions above (proposal-delta.txt). lib.rs, feed_stream.rs and changes.rs have new blobs only because main changed them; their proposal hunks are unchanged.
  • Inverse: git apply -R new-proposal.patch on 973ff3e gives exactly the 4b2372a tree.

Gates on 973ff3e:

  • default lib 2843/0 under the HOME-escape guard (clean; command exit 0);
  • server --features full 241/0;
  • the as-of, replay and applier tests 60/0;
  • clippy -D warnings -A missing-docs rc 0 for full and shacl,owl;
  • fmt and the file-size ratchet clean.

Fresh CI is owed. @muldoon, please do the SAME review on the delta only.

🤖 Generated with Claude Code

@scbrown
scbrown merged commit 2733fef into main Oct 24, 2026
27 checks passed
@scbrown
scbrown deleted the malcolm/s241-asof-cut branch October 24, 2026 18:20
scbrown added a commit that referenced this pull request Oct 24, 2026
…seam

Now that #805 is on main: a real asof_share::cut of a file-backed source
(300 items, unicode and escaped titles, bytes, a superseded value), framed
as the server sends it (one quipu.asof frame, or begin/parts/commit),
bootstraps an empty replica to the source's live facts; one tampered part
with the chain left intact is refused with nothing written.

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

* fix(changes): carry valid time and byte bodies so a feed replica equals its source

A replica built only from /changes records diverged from its source in two
ways (local-sync-mode.md F3, F4):

- Records carried no valid time, so a fact valid since 2025 replayed as valid
  from its commit time. Records now carry valid_from and valid_to. A later
  retract closes the asserted row in place, so a row a later transaction
  closed reports valid_to null and the closing arrives as the retract's own
  record. Otherwise a served record would change after the fact, and a
  replica would assert an already-closed fact its retract could not find.
- Byte values were reduced to bytes_len. They now also carry the body as
  base64.

Both are additive fields. New replay tests rebuild a store from the feed and
compare a canonical live-fact digest: a plain control, valid time (open and
bounded), bytes, and an assert record that reads the same before and after a
later retract. Removing each change turns its test red.

Refs aegis-xdvg.1.

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

* fix(owl): write a retract row when a functional value is superseded

A functional-property supersede closed the prior value with an UPDATE and
wrote no op=0 row, unlike an explicit retract. The change feed derives its
records from rows, so it showed the new value and never the end of the old
one, and a replica built from the feed kept both (local-sync-mode.md F1).

The supersede now writes the same immutable op=0 row an explicit retract
writes, at the superseding transaction. The resident read model reads op=1
rows and their retracted_tx stamp, so it is unaffected.

The replay test asserts the replica holds exactly one current value;
removing the new row 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(stream): send an oversized transaction as parts instead of ending the stream

A /changes/stream page larger than the 1 MiB frame budget sent quipu.error
without advancing the cursor and ended the stream. A consumer reconnecting at
that cursor hit the same transaction and the same dead end forever
(local-sync-mode.md F6).

Such a transaction now arrives as quipu.changes.begin, numbered
quipu.changes.part frames and a quipu.changes.commit barrier (design §6).
Only the barrier carries the SSE id, so Last-Event-ID never lands inside a
transaction and a reconnect replays it from the first part. The barrier digest
is SHA-256 over the parts' data exactly as sent; each part carries the hash of
the parts before it. A single record larger than a frame is refused with
quipu.changes.tx_too_large and no id, leaving the cursor unchanged.

Tests stream a 2500-record transaction: every part fits, the digests chain,
the reassembled records equal the page, the next transaction follows, and a
reconnect replays from part 1. Disabling the split turns the test red.

Refs aegis-xdvg.1.

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

* refactor(stream): build oversized-transaction frames independent of SSE

chunk_transaction now returns transport-neutral frames (event name,
optional cursor id, data string), and the SSE stream turns them into
events only at the edge. Another transport can send the same frames and
the same digests unchanged. A new test checks that the frames SSE sends
equal the ones chunk_transaction returns, byte for byte.

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

* feat(changes): name the source store and log epoch on every page

A change page did not say which store produced it, and nothing marked a
history boundary, so a follower could not tell a restored or cloned primary,
or one that had removed served records, from the one it was following
(local-sync-mode.md §5, F5).

Every page now carries source.store_id and source.log_epoch. The epoch lives
in a new one-row log_epoch table, minted when the store is created (or first
opened by this version) and minted again by Store::mint_log_epoch for an
operation that removes served history. Share reconstruction excludes the
table, so a restored copy mints its own epoch and its followers re-bootstrap.
The explicit restore/adopt ownership step is deferred to aegis-s24.2, per the
xdvg.1 ruling.

Tests cover the page source, an epoch stable across reopen, a new epoch after
minting, and a store that predates the table getting one on first open.
Removing the field turns both tests red.

Refs aegis-xdvg.1.

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

* fix(stream): name the source on a chunked transaction's begin and commit

An oversized transaction's begin and commit frames carried no source,
so a follower could not epoch-check a chunked transaction the way it
checks a whole page, although a changed epoch must mean re-bootstrap,
never splice. Both frames now carry the page's {store_id, log_epoch}.

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

* feat(applier): the shared applier, slice 1: a replica from the shipped feed

One component turns the committed feed into a read-only replica, so the HA
follower, a scoped follower and the in-page browser store never write their
own projection logic (docs/design/shared-applier-api.md). This slice applies
the frames that ship today, given as (event, data) text exactly as SSE or a
WebSocket delivers them: /changes pages and the begin/part/commit frames of
an oversized transaction. It does no I/O, so it builds for wasm32 too.

- The first frame binds the replica to its source (store_id, log_epoch); a
  frame from another source returns Rebootstrap, never a splice.
- Each source transaction applies in one savepoint with the persisted
  cursor; a chunked one is buffered, its running digest checked part by
  part, and nothing is visible until the commit verifies (or the caller is
  told to resume from the cursor).
- A record the replica cannot reproduce exactly (a byte value sent as its
  length only, an unknown value shape) refuses the whole transaction.
- Freshness is (last applied tx, time since the last frame), never bounded.

The feed-replay conformance tests now use the same record->datum
conversion (record_to_datum) and share live_digest, so there is one mapping.
The cursor table is excluded from shares, like the log epoch it is bound to.

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

* test(applier): a digest-sensitivity control and the bridge's JSON contract

The replica equality oracle now sits beside the code it judges, so prove
it sees valid time, byte content and graph (review ask). Outcome and
Freshness serialize tagged by kind, the contract a browser bridge returns.

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

* fix(applier): refuse a gap; a page names the cursor it answers

A /changes page covers every transaction in its scope after the cursor it
was asked for, but did not say which cursor that was. Transaction ids are
global and a per-graph page skips other graphs' ids, so the applier could
not tell a lost page from an ordinary skip and would splice past it.

- Every page now carries `since`, and the chunked `begin` and `commit`
  frames repeat it.
- The applier resumes (nothing applied) when a page or a chunked
  transaction answers a cursor above its own. A new replica starts at 0
  until a bootstrap exists. A page that names no `since` is refused.
- A page arriving while a chunked transaction is open discards the chunk
  and resumes. Before, the page could apply the chunk's transaction, move
  the cursor past it, and the commit was then dropped as a duplicate.

Seven tests:
- a dropped page;
- a first page not at 0;
- a page without `since`;
- a page inside an open chunk;
- an interrupted chunk restarted from a new begin;
- a re-opened applier holding no staged parts;
- a chunk that skips a transaction.
With the two checks disabled, four of them fail.

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

* fix(knowledge): refresh the src/lib.rs source witness after adding the applier

The applier commit added `pub mod applier` to src/lib.rs, so its
repository-knowledge witness hash went stale (the pendant source controls
job). The record's summary still holds; only the hash changes.

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

* feat(applier): slice 2, a replica bootstrapped from an as-of share

The shared applier's bootstrap (docs/design/as-of-share.md). A share carries
the source's live facts at its watermark as ordinary change records, so
bootstrap and tail use ONE record_to_datum and prove equality with the same
live digest. It arrives as one `quipu.asof` frame, or as
`quipu.asof.begin` / `.part` / `.commit` with the same digest chain as a
chunked transaction (the part and commit checks are now shared `Chunk`
methods).

All or nothing. These refuse with nothing written:
- a replica that is not empty;
- a manifest that is not quipu-asof-share/v1, or does not claim valid time
  and lossless bytes;
- a record that is not an assert, or is in a graph the manifest does not
  list;
- a graph whose records do not match the manifest's count and digest
  (SHA-256 over sorted, key-sorted compact JSON lines, `share_graph_digest`).
On success the facts and the cursor (store_id, log_epoch, watermark_tx) commit
in one savepoint, and slice 1 tails from /changes?since=watermark.

Seven tests:
- bootstrap then tail to equality with the source (valid time, bytes, named
  graph, a later retraction), and the cursor survives reopening;
- chunked equals single-frame, and nothing is visible before the commit;
- a dropped part resumes with nothing written, and a restarted cut converges;
- every untrustworthy share refused, with nothing written and no cursor;
- only an empty replica;
- a new epoch after bootstrap means rebootstrap;
- the digest ignores key and record order but not content.
With the count/digest and emptiness checks disabled, two tests fail.

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

* feat(bindings): a replica through the binding facade and the browser Database

The shared applier joins the binding facade, so the in-page store and any
other binding reach it through one engine: Engine::replica_apply_frame,
replica_progress and replica_freshness, exported to JavaScript on the wasm
Database as replicaApplyFrame / replicaProgress / replicaFreshness (JSON text
in the contract Outcome and Freshness serialize to).

- The first replica call makes the store a read-only replica: local writes
  through invoke (knot, episode, set, retract, shapes load/remove) are
  refused before dispatch with read_only_replica / not_started. Reads work.
- The applier writes what the source already accepted, so local write
  validation is suspended for the applier's own writes and restored after.
  Re-judging source data against local shapes could refuse it and fork the
  replica silently. A test proves both halves: the adopted shape refuses the
  data when written locally, and the replica applies it from the feed.

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

* fix(knowledge): refresh the rest-api.md source witness

15c8128 changed docs/book/src/reference/rest-api.md without moving its
witness, so the repository-knowledge check refused the registry. Only the
source_sha256 moves.

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

* feat(applier): a duplicate is checked against the retained transaction digest

Rule 6 of the shared applier API (docs/design/shared-applier-api.md): a
transaction at or below the cursor was skipped as a duplicate without
looking at it, so a redelivered tx 5 with changed records answered exactly
like an exact replay.

The applier now computes each transaction's content digest itself from the
decoded records (domain tag, store id, log epoch, source tx, then each
record's length-prefixed exact encoding, sorted), independent of framing,
and retains it in replica_applied_tx in the same savepoint as the records
and the cursor. A redelivery is Duplicate only when its digest equals the
retained one; a different digest is Stopped { DigestMismatch }, no
retained row is Stopped { DigestUnverifiable }, nothing is written, and the
stop is sticky. A frame from a new source still answers Rebootstrap.

replica_applied_tx is excluded from shares with the cursor it belongs to.

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

* fix(tests): the bindings timestamp constant is not shacl-only

record_source uses TS in every build, so the no-default-features test
build failed to compile (E0425).

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

* fix(applier): stream an as-of share into the replica instead of holding it

Slice 2 buffered every part of a chunked share in memory until the commit
(found on a 9.16M-record share). The begin frame now opens ONE local
savepoint; each part's facts are written as it arrives, in batches the
size of a part, and its canonical lines go to a temporary table so the
per-graph digests are computed in sorted order at the commit. Any failed
check rolls the savepoint back with nothing written, as before; the facts
and the cursor still commit together.

Same-connection reads now see a share's parts before its commit. The
replica has no cursor until Bootstrapped, and a reader must not use it
before then. A tail frame mid-share aborts the share, and an applier
opened on a store left mid-share rolls it back.

The digest chain moves to src/applier_chunk.rs and counts records without
keeping them when the caller streams. examples/applier_bootstrap_rss
measures peak RSS for a synthetic share at a chosen size.

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

* fix(applier): a stop persists across reopening, and an unknown op is refused

Review findings on the shared applier:

- P1: the sticky Stopped outcome lived only in memory, so a fresh Applier
  on the same replica tailed on past a recorded digest mismatch. The stop
  is now kept in replica_stop (first stop wins) and restored by open; a
  replica whose history disagreed with its source stays stopped until it
  is rebuilt.
- P2: any op other than "retract" was applied as an assert. Only
  "assert" and "retract" are accepted, in the record conversion and the
  transaction digest alike; anything else refuses the transaction with
  nothing written.

replica_stop is excluded from shares with the cursor it belongs to.
Controls: a stop survives reopening (fails with open ignoring the stop),
and an unknown op is refused (fails with unknown ops read as assert).

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

* fix(applier): a store holding local facts never becomes a replica

Review 6387176506 (CHANGES): replica_apply_frame promoted any Engine to a
read-only replica on its first call, and the applier tailed the feed
into a store that already held local facts. The replica would then equal
no source while its cursor and digests claim it does.

- Applier::new_replica_refusal: a store may start a replica only if it
  already follows a source or holds no facts (shapes are not facts).
- The applier refuses a tail into such a store with nothing written.
- The binding checks before promoting: the frame is refused and the
  store stays an ordinary writable store.

Paired controls: with either gate removed, its test fails.
record_to_datum and value_of move unchanged to applier_record.rs, and the
gate lives in applier_start.rs, to keep applier.rs under the size gate.

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

* test(applier): the as-of share producer and the applier agree at the seam

Now that #805 is on main: a real asof_share::cut of a file-backed source
(300 items, unicode and escaped titles, bytes, a superseded value), framed
as the server sends it (one quipu.asof frame, or begin/parts/commit),
bootstraps an empty replica to the source's live facts; one tampered part
with the chain left intact is refused with nothing written.

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

* fix(applier): a store error mid-share rolls the whole share back

Review 6388941162 (CHANGES P2): when the progress write failed after the
share was taken, finish_share returned the error with the bootstrap
savepoint still open, so the share's facts stayed visible on the same
connection until a reopen.

Every store error while a share is open now discards the share and its
savepoint before the error is returned (bootstrap_frame wraps the share
handling once, so verify, write and finish errors are all covered).

Paired control: a_store_error_mid_share_leaves_no_fact_visible_even_on_the_same_connection
fails with the rollback removed ("the share's savepoint is still open")
and passes with it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@scbrown scbrown mentioned this pull request Oct 25, 2026
scbrown added a commit that referenced this pull request Oct 25, 2026
…shness challenge (#816)

* fix(changes): carry valid time and byte bodies so a feed replica equals its source

A replica built only from /changes records diverged from its source in two
ways (local-sync-mode.md F3, F4):

- Records carried no valid time, so a fact valid since 2025 replayed as valid
  from its commit time. Records now carry valid_from and valid_to. A later
  retract closes the asserted row in place, so a row a later transaction
  closed reports valid_to null and the closing arrives as the retract's own
  record. Otherwise a served record would change after the fact, and a
  replica would assert an already-closed fact its retract could not find.
- Byte values were reduced to bytes_len. They now also carry the body as
  base64.

Both are additive fields. New replay tests rebuild a store from the feed and
compare a canonical live-fact digest: a plain control, valid time (open and
bounded), bytes, and an assert record that reads the same before and after a
later retract. Removing each change turns its test red.

Refs aegis-xdvg.1.

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

* fix(owl): write a retract row when a functional value is superseded

A functional-property supersede closed the prior value with an UPDATE and
wrote no op=0 row, unlike an explicit retract. The change feed derives its
records from rows, so it showed the new value and never the end of the old
one, and a replica built from the feed kept both (local-sync-mode.md F1).

The supersede now writes the same immutable op=0 row an explicit retract
writes, at the superseding transaction. The resident read model reads op=1
rows and their retracted_tx stamp, so it is unaffected.

The replay test asserts the replica holds exactly one current value;
removing the new row 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(stream): send an oversized transaction as parts instead of ending the stream

A /changes/stream page larger than the 1 MiB frame budget sent quipu.error
without advancing the cursor and ended the stream. A consumer reconnecting at
that cursor hit the same transaction and the same dead end forever
(local-sync-mode.md F6).

Such a transaction now arrives as quipu.changes.begin, numbered
quipu.changes.part frames and a quipu.changes.commit barrier (design §6).
Only the barrier carries the SSE id, so Last-Event-ID never lands inside a
transaction and a reconnect replays it from the first part. The barrier digest
is SHA-256 over the parts' data exactly as sent; each part carries the hash of
the parts before it. A single record larger than a frame is refused with
quipu.changes.tx_too_large and no id, leaving the cursor unchanged.

Tests stream a 2500-record transaction: every part fits, the digests chain,
the reassembled records equal the page, the next transaction follows, and a
reconnect replays from part 1. Disabling the split turns the test red.

Refs aegis-xdvg.1.

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

* refactor(stream): build oversized-transaction frames independent of SSE

chunk_transaction now returns transport-neutral frames (event name,
optional cursor id, data string), and the SSE stream turns them into
events only at the edge. Another transport can send the same frames and
the same digests unchanged. A new test checks that the frames SSE sends
equal the ones chunk_transaction returns, byte for byte.

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

* feat(changes): name the source store and log epoch on every page

A change page did not say which store produced it, and nothing marked a
history boundary, so a follower could not tell a restored or cloned primary,
or one that had removed served records, from the one it was following
(local-sync-mode.md §5, F5).

Every page now carries source.store_id and source.log_epoch. The epoch lives
in a new one-row log_epoch table, minted when the store is created (or first
opened by this version) and minted again by Store::mint_log_epoch for an
operation that removes served history. Share reconstruction excludes the
table, so a restored copy mints its own epoch and its followers re-bootstrap.
The explicit restore/adopt ownership step is deferred to aegis-s24.2, per the
xdvg.1 ruling.

Tests cover the page source, an epoch stable across reopen, a new epoch after
minting, and a store that predates the table getting one on first open.
Removing the field turns both tests red.

Refs aegis-xdvg.1.

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

* fix(stream): name the source on a chunked transaction's begin and commit

An oversized transaction's begin and commit frames carried no source,
so a follower could not epoch-check a chunked transaction the way it
checks a whole page, although a changed epoch must mean re-bootstrap,
never splice. Both frames now carry the page's {store_id, log_epoch}.

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

* feat(applier): the shared applier, slice 1: a replica from the shipped feed

One component turns the committed feed into a read-only replica, so the HA
follower, a scoped follower and the in-page browser store never write their
own projection logic (docs/design/shared-applier-api.md). This slice applies
the frames that ship today, given as (event, data) text exactly as SSE or a
WebSocket delivers them: /changes pages and the begin/part/commit frames of
an oversized transaction. It does no I/O, so it builds for wasm32 too.

- The first frame binds the replica to its source (store_id, log_epoch); a
  frame from another source returns Rebootstrap, never a splice.
- Each source transaction applies in one savepoint with the persisted
  cursor; a chunked one is buffered, its running digest checked part by
  part, and nothing is visible until the commit verifies (or the caller is
  told to resume from the cursor).
- A record the replica cannot reproduce exactly (a byte value sent as its
  length only, an unknown value shape) refuses the whole transaction.
- Freshness is (last applied tx, time since the last frame), never bounded.

The feed-replay conformance tests now use the same record->datum
conversion (record_to_datum) and share live_digest, so there is one mapping.
The cursor table is excluded from shares, like the log epoch it is bound to.

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

* test(applier): a digest-sensitivity control and the bridge's JSON contract

The replica equality oracle now sits beside the code it judges, so prove
it sees valid time, byte content and graph (review ask). Outcome and
Freshness serialize tagged by kind, the contract a browser bridge returns.

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

* fix(applier): refuse a gap; a page names the cursor it answers

A /changes page covers every transaction in its scope after the cursor it
was asked for, but did not say which cursor that was. Transaction ids are
global and a per-graph page skips other graphs' ids, so the applier could
not tell a lost page from an ordinary skip and would splice past it.

- Every page now carries `since`, and the chunked `begin` and `commit`
  frames repeat it.
- The applier resumes (nothing applied) when a page or a chunked
  transaction answers a cursor above its own. A new replica starts at 0
  until a bootstrap exists. A page that names no `since` is refused.
- A page arriving while a chunked transaction is open discards the chunk
  and resumes. Before, the page could apply the chunk's transaction, move
  the cursor past it, and the commit was then dropped as a duplicate.

Seven tests:
- a dropped page;
- a first page not at 0;
- a page without `since`;
- a page inside an open chunk;
- an interrupted chunk restarted from a new begin;
- a re-opened applier holding no staged parts;
- a chunk that skips a transaction.
With the two checks disabled, four of them fail.

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

* fix(knowledge): refresh the src/lib.rs source witness after adding the applier

The applier commit added `pub mod applier` to src/lib.rs, so its
repository-knowledge witness hash went stale (the pendant source controls
job). The record's summary still holds; only the hash changes.

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

* feat(applier): slice 2, a replica bootstrapped from an as-of share

The shared applier's bootstrap (docs/design/as-of-share.md). A share carries
the source's live facts at its watermark as ordinary change records, so
bootstrap and tail use ONE record_to_datum and prove equality with the same
live digest. It arrives as one `quipu.asof` frame, or as
`quipu.asof.begin` / `.part` / `.commit` with the same digest chain as a
chunked transaction (the part and commit checks are now shared `Chunk`
methods).

All or nothing. These refuse with nothing written:
- a replica that is not empty;
- a manifest that is not quipu-asof-share/v1, or does not claim valid time
  and lossless bytes;
- a record that is not an assert, or is in a graph the manifest does not
  list;
- a graph whose records do not match the manifest's count and digest
  (SHA-256 over sorted, key-sorted compact JSON lines, `share_graph_digest`).
On success the facts and the cursor (store_id, log_epoch, watermark_tx) commit
in one savepoint, and slice 1 tails from /changes?since=watermark.

Seven tests:
- bootstrap then tail to equality with the source (valid time, bytes, named
  graph, a later retraction), and the cursor survives reopening;
- chunked equals single-frame, and nothing is visible before the commit;
- a dropped part resumes with nothing written, and a restarted cut converges;
- every untrustworthy share refused, with nothing written and no cursor;
- only an empty replica;
- a new epoch after bootstrap means rebootstrap;
- the digest ignores key and record order but not content.
With the count/digest and emptiness checks disabled, two tests fail.

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

* feat(bindings): a replica through the binding facade and the browser Database

The shared applier joins the binding facade, so the in-page store and any
other binding reach it through one engine: Engine::replica_apply_frame,
replica_progress and replica_freshness, exported to JavaScript on the wasm
Database as replicaApplyFrame / replicaProgress / replicaFreshness (JSON text
in the contract Outcome and Freshness serialize to).

- The first replica call makes the store a read-only replica: local writes
  through invoke (knot, episode, set, retract, shapes load/remove) are
  refused before dispatch with read_only_replica / not_started. Reads work.
- The applier writes what the source already accepted, so local write
  validation is suspended for the applier's own writes and restored after.
  Re-judging source data against local shapes could refuse it and fork the
  replica silently. A test proves both halves: the adopted shape refuses the
  data when written locally, and the replica applies it from the feed.

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

* fix(knowledge): refresh the rest-api.md source witness

15c8128 changed docs/book/src/reference/rest-api.md without moving its
witness, so the repository-knowledge check refused the registry. Only the
source_sha256 moves.

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

* feat(applier): a duplicate is checked against the retained transaction digest

Rule 6 of the shared applier API (docs/design/shared-applier-api.md): a
transaction at or below the cursor was skipped as a duplicate without
looking at it, so a redelivered tx 5 with changed records answered exactly
like an exact replay.

The applier now computes each transaction's content digest itself from the
decoded records (domain tag, store id, log epoch, source tx, then each
record's length-prefixed exact encoding, sorted), independent of framing,
and retains it in replica_applied_tx in the same savepoint as the records
and the cursor. A redelivery is Duplicate only when its digest equals the
retained one; a different digest is Stopped { DigestMismatch }, no
retained row is Stopped { DigestUnverifiable }, nothing is written, and the
stop is sticky. A frame from a new source still answers Rebootstrap.

replica_applied_tx is excluded from shares with the cursor it belongs to.

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

* fix(tests): the bindings timestamp constant is not shacl-only

record_source uses TS in every build, so the no-default-features test
build failed to compile (E0425).

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

* fix(applier): stream an as-of share into the replica instead of holding it

Slice 2 buffered every part of a chunked share in memory until the commit
(found on a 9.16M-record share). The begin frame now opens ONE local
savepoint; each part's facts are written as it arrives, in batches the
size of a part, and its canonical lines go to a temporary table so the
per-graph digests are computed in sorted order at the commit. Any failed
check rolls the savepoint back with nothing written, as before; the facts
and the cursor still commit together.

Same-connection reads now see a share's parts before its commit. The
replica has no cursor until Bootstrapped, and a reader must not use it
before then. A tail frame mid-share aborts the share, and an applier
opened on a store left mid-share rolls it back.

The digest chain moves to src/applier_chunk.rs and counts records without
keeping them when the caller streams. examples/applier_bootstrap_rss
measures peak RSS for a synthetic share at a chosen size.

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

* fix(applier): a stop persists across reopening, and an unknown op is refused

Review findings on the shared applier:

- P1: the sticky Stopped outcome lived only in memory, so a fresh Applier
  on the same replica tailed on past a recorded digest mismatch. The stop
  is now kept in replica_stop (first stop wins) and restored by open; a
  replica whose history disagreed with its source stays stopped until it
  is rebuilt.
- P2: any op other than "retract" was applied as an assert. Only
  "assert" and "retract" are accepted, in the record conversion and the
  transaction digest alike; anything else refuses the transaction with
  nothing written.

replica_stop is excluded from shares with the cursor it belongs to.
Controls: a stop survives reopening (fails with open ignoring the stop),
and an unknown op is refused (fails with unknown ops read as assert).

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

* feat(applier): slice 3a, stream sessions, signed coverage and the freshness challenge

The applier's side of the scoped replication stream (local-sync-mode.md
§5, shared-applier-api.md rules 1-8):

- A signed session frame binds a session id to the source, the pinned
  scope digest and its since; a since above the cursor resumes.
- frame_seq must be exactly last + 1, and each transaction newer than
  the last the session sent; anything else drops the session and resumes.
- Coverage is verified against a caller-pinned stream key (never learned
  from the stream), persisted beside the cursor, and never moves back.
- Freshness is bounded only by a coverage frame answering an outstanding
  challenge of the open session, consumed on use. A heartbeat, a replayed
  or unsolicited nonce, a new session, a reopen, end_session or a clock
  discontinuity never yields a bound.
- Control frames: revoked (persisted, sticky across reopening),
  epoch_end (rebootstrap).

replicate_wire.rs holds the signed bytes, shared with the producer that
follows: domain tag quipu-replicate/v1, then length-prefixed fields.

22 stream tests; each of 12 mutations of the checks fails at least one.

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

* fix(applier): a store holding local facts never becomes a replica

Review 6387176506 (CHANGES): replica_apply_frame promoted any Engine to a
read-only replica on its first call, and the applier tailed the feed
into a store that already held local facts. The replica would then equal
no source while its cursor and digests claim it does.

- Applier::new_replica_refusal: a store may start a replica only if it
  already follows a source or holds no facts (shapes are not facts).
- The applier refuses a tail into such a store with nothing written.
- The binding checks before promoting: the frame is refused and the
  store stays an ordinary writable store.

Paired controls: with either gate removed, its test fails.
record_to_datum and value_of move unchanged to applier_record.rs, and the
gate lives in applier_start.rs, to keep applier.rs under the size gate.

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

* fix(bindings): carry the stream freshness fields on a store that is not a replica

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

* test(applier): the as-of share producer and the applier agree at the seam

Now that #805 is on main: a real asof_share::cut of a file-backed source
(300 items, unicode and escaped titles, bytes, a superseded value), framed
as the server sends it (one quipu.asof frame, or begin/parts/commit),
bootstraps an empty replica to the source's live facts; one tampered part
with the chain left intact is refused with nothing written.

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

* fix(applier): keep coverage-only anchors and end the session on chunk recovery

Review findings on #816 (non-author review):

P1. A scoped stream can verify coverage before its first matching
transaction. That coverage was persisted, but on reopen Stream::load
read it only for the source of applied progress, and there is none, so
the cursor was lost. Coverage now loads with the source it was verified
from. check_source anchors on applied progress or, before any, on that
covered source, so another source or epoch still asks for a rebootstrap.

P2. A chunk part or commit that broke returned Resume directly. That
cleared the chunk but kept the stream session and any earlier proof.
Every in-session Resume now goes through fault(), as a sequence gap
already did.

Controls, each failing with its repair reverted:
- coverage before any transaction anchors across reopen: W kept, a
  session at W opens, one past W resumes from W;
- coverage from one source refuses another, before and after reopen;
- a broken chunk ends the session and its proofs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
scbrown added a commit that referenced this pull request Oct 25, 2026
…lenge (#817)

* fix(changes): carry valid time and byte bodies so a feed replica equals its source

A replica built only from /changes records diverged from its source in two
ways (local-sync-mode.md F3, F4):

- Records carried no valid time, so a fact valid since 2025 replayed as valid
  from its commit time. Records now carry valid_from and valid_to. A later
  retract closes the asserted row in place, so a row a later transaction
  closed reports valid_to null and the closing arrives as the retract's own
  record. Otherwise a served record would change after the fact, and a
  replica would assert an already-closed fact its retract could not find.
- Byte values were reduced to bytes_len. They now also carry the body as
  base64.

Both are additive fields. New replay tests rebuild a store from the feed and
compare a canonical live-fact digest: a plain control, valid time (open and
bounded), bytes, and an assert record that reads the same before and after a
later retract. Removing each change turns its test red.

Refs aegis-xdvg.1.

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

* fix(owl): write a retract row when a functional value is superseded

A functional-property supersede closed the prior value with an UPDATE and
wrote no op=0 row, unlike an explicit retract. The change feed derives its
records from rows, so it showed the new value and never the end of the old
one, and a replica built from the feed kept both (local-sync-mode.md F1).

The supersede now writes the same immutable op=0 row an explicit retract
writes, at the superseding transaction. The resident read model reads op=1
rows and their retracted_tx stamp, so it is unaffected.

The replay test asserts the replica holds exactly one current value;
removing the new row 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(stream): send an oversized transaction as parts instead of ending the stream

A /changes/stream page larger than the 1 MiB frame budget sent quipu.error
without advancing the cursor and ended the stream. A consumer reconnecting at
that cursor hit the same transaction and the same dead end forever
(local-sync-mode.md F6).

Such a transaction now arrives as quipu.changes.begin, numbered
quipu.changes.part frames and a quipu.changes.commit barrier (design §6).
Only the barrier carries the SSE id, so Last-Event-ID never lands inside a
transaction and a reconnect replays it from the first part. The barrier digest
is SHA-256 over the parts' data exactly as sent; each part carries the hash of
the parts before it. A single record larger than a frame is refused with
quipu.changes.tx_too_large and no id, leaving the cursor unchanged.

Tests stream a 2500-record transaction: every part fits, the digests chain,
the reassembled records equal the page, the next transaction follows, and a
reconnect replays from part 1. Disabling the split turns the test red.

Refs aegis-xdvg.1.

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

* refactor(stream): build oversized-transaction frames independent of SSE

chunk_transaction now returns transport-neutral frames (event name,
optional cursor id, data string), and the SSE stream turns them into
events only at the edge. Another transport can send the same frames and
the same digests unchanged. A new test checks that the frames SSE sends
equal the ones chunk_transaction returns, byte for byte.

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

* feat(changes): name the source store and log epoch on every page

A change page did not say which store produced it, and nothing marked a
history boundary, so a follower could not tell a restored or cloned primary,
or one that had removed served records, from the one it was following
(local-sync-mode.md §5, F5).

Every page now carries source.store_id and source.log_epoch. The epoch lives
in a new one-row log_epoch table, minted when the store is created (or first
opened by this version) and minted again by Store::mint_log_epoch for an
operation that removes served history. Share reconstruction excludes the
table, so a restored copy mints its own epoch and its followers re-bootstrap.
The explicit restore/adopt ownership step is deferred to aegis-s24.2, per the
xdvg.1 ruling.

Tests cover the page source, an epoch stable across reopen, a new epoch after
minting, and a store that predates the table getting one on first open.
Removing the field turns both tests red.

Refs aegis-xdvg.1.

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

* fix(stream): name the source on a chunked transaction's begin and commit

An oversized transaction's begin and commit frames carried no source,
so a follower could not epoch-check a chunked transaction the way it
checks a whole page, although a changed epoch must mean re-bootstrap,
never splice. Both frames now carry the page's {store_id, log_epoch}.

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

* feat(applier): the shared applier, slice 1: a replica from the shipped feed

One component turns the committed feed into a read-only replica, so the HA
follower, a scoped follower and the in-page browser store never write their
own projection logic (docs/design/shared-applier-api.md). This slice applies
the frames that ship today, given as (event, data) text exactly as SSE or a
WebSocket delivers them: /changes pages and the begin/part/commit frames of
an oversized transaction. It does no I/O, so it builds for wasm32 too.

- The first frame binds the replica to its source (store_id, log_epoch); a
  frame from another source returns Rebootstrap, never a splice.
- Each source transaction applies in one savepoint with the persisted
  cursor; a chunked one is buffered, its running digest checked part by
  part, and nothing is visible until the commit verifies (or the caller is
  told to resume from the cursor).
- A record the replica cannot reproduce exactly (a byte value sent as its
  length only, an unknown value shape) refuses the whole transaction.
- Freshness is (last applied tx, time since the last frame), never bounded.

The feed-replay conformance tests now use the same record->datum
conversion (record_to_datum) and share live_digest, so there is one mapping.
The cursor table is excluded from shares, like the log epoch it is bound to.

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

* test(applier): a digest-sensitivity control and the bridge's JSON contract

The replica equality oracle now sits beside the code it judges, so prove
it sees valid time, byte content and graph (review ask). Outcome and
Freshness serialize tagged by kind, the contract a browser bridge returns.

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

* fix(applier): refuse a gap; a page names the cursor it answers

A /changes page covers every transaction in its scope after the cursor it
was asked for, but did not say which cursor that was. Transaction ids are
global and a per-graph page skips other graphs' ids, so the applier could
not tell a lost page from an ordinary skip and would splice past it.

- Every page now carries `since`, and the chunked `begin` and `commit`
  frames repeat it.
- The applier resumes (nothing applied) when a page or a chunked
  transaction answers a cursor above its own. A new replica starts at 0
  until a bootstrap exists. A page that names no `since` is refused.
- A page arriving while a chunked transaction is open discards the chunk
  and resumes. Before, the page could apply the chunk's transaction, move
  the cursor past it, and the commit was then dropped as a duplicate.

Seven tests:
- a dropped page;
- a first page not at 0;
- a page without `since`;
- a page inside an open chunk;
- an interrupted chunk restarted from a new begin;
- a re-opened applier holding no staged parts;
- a chunk that skips a transaction.
With the two checks disabled, four of them fail.

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

* fix(knowledge): refresh the src/lib.rs source witness after adding the applier

The applier commit added `pub mod applier` to src/lib.rs, so its
repository-knowledge witness hash went stale (the pendant source controls
job). The record's summary still holds; only the hash changes.

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

* feat(applier): slice 2, a replica bootstrapped from an as-of share

The shared applier's bootstrap (docs/design/as-of-share.md). A share carries
the source's live facts at its watermark as ordinary change records, so
bootstrap and tail use ONE record_to_datum and prove equality with the same
live digest. It arrives as one `quipu.asof` frame, or as
`quipu.asof.begin` / `.part` / `.commit` with the same digest chain as a
chunked transaction (the part and commit checks are now shared `Chunk`
methods).

All or nothing. These refuse with nothing written:
- a replica that is not empty;
- a manifest that is not quipu-asof-share/v1, or does not claim valid time
  and lossless bytes;
- a record that is not an assert, or is in a graph the manifest does not
  list;
- a graph whose records do not match the manifest's count and digest
  (SHA-256 over sorted, key-sorted compact JSON lines, `share_graph_digest`).
On success the facts and the cursor (store_id, log_epoch, watermark_tx) commit
in one savepoint, and slice 1 tails from /changes?since=watermark.

Seven tests:
- bootstrap then tail to equality with the source (valid time, bytes, named
  graph, a later retraction), and the cursor survives reopening;
- chunked equals single-frame, and nothing is visible before the commit;
- a dropped part resumes with nothing written, and a restarted cut converges;
- every untrustworthy share refused, with nothing written and no cursor;
- only an empty replica;
- a new epoch after bootstrap means rebootstrap;
- the digest ignores key and record order but not content.
With the count/digest and emptiness checks disabled, two tests fail.

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

* feat(bindings): a replica through the binding facade and the browser Database

The shared applier joins the binding facade, so the in-page store and any
other binding reach it through one engine: Engine::replica_apply_frame,
replica_progress and replica_freshness, exported to JavaScript on the wasm
Database as replicaApplyFrame / replicaProgress / replicaFreshness (JSON text
in the contract Outcome and Freshness serialize to).

- The first replica call makes the store a read-only replica: local writes
  through invoke (knot, episode, set, retract, shapes load/remove) are
  refused before dispatch with read_only_replica / not_started. Reads work.
- The applier writes what the source already accepted, so local write
  validation is suspended for the applier's own writes and restored after.
  Re-judging source data against local shapes could refuse it and fork the
  replica silently. A test proves both halves: the adopted shape refuses the
  data when written locally, and the replica applies it from the feed.

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

* fix(knowledge): refresh the rest-api.md source witness

15c8128 changed docs/book/src/reference/rest-api.md without moving its
witness, so the repository-knowledge check refused the registry. Only the
source_sha256 moves.

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

* feat(applier): a duplicate is checked against the retained transaction digest

Rule 6 of the shared applier API (docs/design/shared-applier-api.md): a
transaction at or below the cursor was skipped as a duplicate without
looking at it, so a redelivered tx 5 with changed records answered exactly
like an exact replay.

The applier now computes each transaction's content digest itself from the
decoded records (domain tag, store id, log epoch, source tx, then each
record's length-prefixed exact encoding, sorted), independent of framing,
and retains it in replica_applied_tx in the same savepoint as the records
and the cursor. A redelivery is Duplicate only when its digest equals the
retained one; a different digest is Stopped { DigestMismatch }, no
retained row is Stopped { DigestUnverifiable }, nothing is written, and the
stop is sticky. A frame from a new source still answers Rebootstrap.

replica_applied_tx is excluded from shares with the cursor it belongs to.

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

* fix(tests): the bindings timestamp constant is not shacl-only

record_source uses TS in every build, so the no-default-features test
build failed to compile (E0425).

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

* fix(applier): stream an as-of share into the replica instead of holding it

Slice 2 buffered every part of a chunked share in memory until the commit
(found on a 9.16M-record share). The begin frame now opens ONE local
savepoint; each part's facts are written as it arrives, in batches the
size of a part, and its canonical lines go to a temporary table so the
per-graph digests are computed in sorted order at the commit. Any failed
check rolls the savepoint back with nothing written, as before; the facts
and the cursor still commit together.

Same-connection reads now see a share's parts before its commit. The
replica has no cursor until Bootstrapped, and a reader must not use it
before then. A tail frame mid-share aborts the share, and an applier
opened on a store left mid-share rolls it back.

The digest chain moves to src/applier_chunk.rs and counts records without
keeping them when the caller streams. examples/applier_bootstrap_rss
measures peak RSS for a synthetic share at a chosen size.

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

* fix(applier): a stop persists across reopening, and an unknown op is refused

Review findings on the shared applier:

- P1: the sticky Stopped outcome lived only in memory, so a fresh Applier
  on the same replica tailed on past a recorded digest mismatch. The stop
  is now kept in replica_stop (first stop wins) and restored by open; a
  replica whose history disagreed with its source stays stopped until it
  is rebuilt.
- P2: any op other than "retract" was applied as an assert. Only
  "assert" and "retract" are accepted, in the record conversion and the
  transaction digest alike; anything else refuses the transaction with
  nothing written.

replica_stop is excluded from shares with the cursor it belongs to.
Controls: a stop survives reopening (fails with open ignoring the stop),
and an unknown op is refused (fails with unknown ops read as assert).

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

* feat(applier): slice 3a, stream sessions, signed coverage and the freshness challenge

The applier's side of the scoped replication stream (local-sync-mode.md
§5, shared-applier-api.md rules 1-8):

- A signed session frame binds a session id to the source, the pinned
  scope digest and its since; a since above the cursor resumes.
- frame_seq must be exactly last + 1, and each transaction newer than
  the last the session sent; anything else drops the session and resumes.
- Coverage is verified against a caller-pinned stream key (never learned
  from the stream), persisted beside the cursor, and never moves back.
- Freshness is bounded only by a coverage frame answering an outstanding
  challenge of the open session, consumed on use. A heartbeat, a replayed
  or unsolicited nonce, a new session, a reopen, end_session or a clock
  discontinuity never yields a bound.
- Control frames: revoked (persisted, sticky across reopening),
  epoch_end (rebootstrap).

replicate_wire.rs holds the signed bytes, shared with the producer that
follows: domain tag quipu-replicate/v1, then length-prefixed fields.

22 stream tests; each of 12 mutations of the checks fails at least one.

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

* fix(applier): a store holding local facts never becomes a replica

Review 6387176506 (CHANGES): replica_apply_frame promoted any Engine to a
read-only replica on its first call, and the applier tailed the feed
into a store that already held local facts. The replica would then equal
no source while its cursor and digests claim it does.

- Applier::new_replica_refusal: a store may start a replica only if it
  already follows a source or holds no facts (shapes are not facts).
- The applier refuses a tail into such a store with nothing written.
- The binding checks before promoting: the frame is refused and the
  store stays an ordinary writable store.

Paired controls: with either gate removed, its test fails.
record_to_datum and value_of move unchanged to applier_record.rs, and the
gate lives in applier_start.rs, to keep applier.rs under the size gate.

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

* fix(bindings): carry the stream freshness fields on a store that is not a replica

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

* test(applier): the as-of share producer and the applier agree at the seam

Now that #805 is on main: a real asof_share::cut of a file-backed source
(300 items, unicode and escaped titles, bytes, a superseded value), framed
as the server sends it (one quipu.asof frame, or begin/parts/commit),
bootstraps an empty replica to the source's live facts; one tampered part
with the chain left intact is refused with nothing written.

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

* feat(replicate): the scoped replication stream and its freshness challenge

The source side of local-sync-mode.md §5, slice 3b of the shared applier:

- POST /replicate/stream: one SSE session per follower over the scope an
  as-of share would serve (same rules via asof_share::scope_lists and
  resolve_graphs, now shared, and the same scope digest). Frames: a signed
  session frame, one tx frame per source transaction (all its in-scope
  graphs; begin/parts/commit when too large), signed coverage after each
  batch and as a 15 s heartbeat, and signed control (epoch_end,
  tx_too_large).
- Coverage W is read in ONE read transaction with the page: the head when
  the page reached it, else the page's last transaction.
- POST /replicate/challenge delivers a nonce to its session's producer,
  which answers it from a read made after receipt that reached the head.
- Signed with the server's existing ed25519 identity under the
  quipu-replicate/v1 domain; a server without a key serves no stream.
- Both routes are write-class (fail-closed like /share/asof) until
  per-graph read authorization has its ruling.

Supporting changes: Store::changes_in_graphs (a graph-set page),
PartBuilder::in_session (parts carry session and frame_seq inside the
digested text), and the applier maps tx_too_large to Rebootstrap.

Tests: 8 library tests drive sessions into the applier (scoped equality,
tail and filtered no-op, challenge only at the head, heartbeat, parts,
epoch end, refusals, scope digest); 6 server tests (stream-only follower
follows a new commit within 5 s, challenge through the route bounds
freshness, 404/400/503 refusals, disconnect frees the session); 5 producer
mutations each fail a test.

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

* style(replicate): borrow a transaction's records when framing them

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

* style(replicate): let-else in the producer loop; read SSE chunks as str directly

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

* fix(applier): keep coverage-only anchors and end the session on chunk recovery

Review findings on #816 (non-author review):

P1. A scoped stream can verify coverage before its first matching
transaction. That coverage was persisted, but on reopen Stream::load
read it only for the source of applied progress, and there is none, so
the cursor was lost. Coverage now loads with the source it was verified
from. check_source anchors on applied progress or, before any, on that
covered source, so another source or epoch still asks for a rebootstrap.

P2. A chunk part or commit that broke returned Resume directly. That
cleared the chunk but kept the stream session and any earlier proof.
Every in-session Resume now goes through fault(), as a sequence gap
already did.

Controls, each failing with its repair reverted:
- coverage before any transaction anchors across reopen: W kept, a
  session at W opens, one past W resumes from W;
- coverage from one source refuses another, before and after reopen;
- a broken chunk ends the session and its proofs.

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

* refactor(asof-share): move the shared scope checks into asof_share/scope.rs

The merge with #831 took src/asof_share.rs to 526 code lines (limit 500).
scope_lists, resolve_graphs and refuse_frozen, the checks the cut and the
replication stream share, now live in their own submodule and are
re-exported unchanged, so their callers do not change.

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

* fix(replicate-stream): open in one snapshot; bound a transaction before it is built

Review findings on the stream producer:

- P1: Session::open checked the scope and frozen graphs, then read the
  log epoch, as separate reads. A freeze committed between them opened
  the session on the NEW epoch with a scope checked before the freeze,
  and the first poll signed coverage over zero records. Open now reads
  the epoch first and every check in one read snapshot. A change during
  the open leaves the session on the old epoch, and its first poll ends
  it with epoch_end.
- P2: only a single record larger than a frame was refused, so a
  transaction of many small records was built whole in memory. A
  bounded pre-pass (Store::tx_sizes_in_graphs) counts in-scope rows and
  value bytes in SQL. A transaction over Limits::tx_max_rows or
  tx_max_bytes ends the session with tx_too_large and the cursor
  unchanged. A page stops at page_max_rows, and its coverage reaches
  only what it sent.

Tests: a mid-open freeze through a second handle; a refused oversized
transaction with the follower cursor held; a page cut at the row budget
that still converges; an oversized transaction later in a page. Each
fails under its mutant (epoch-last order, no snapshot, no refusal, no
cut, coverage ignoring the cut).

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

* fix(replicate-stream): bound a transaction's encoded size, not its value bytes

Review residual on the stream pre-pass: SUM(length(v)) counted only the
value blobs. A Ref's blob is its 9-byte id, and every record also carries
its entity, attribute and graph IRIs plus the transaction's timestamp,
actor and source, so a transaction with small values could still build
far more than the limit.

Store::tx_encoded_bound is now an upper bound on what changes_in_graphs
builds, read without building it:
- IRI lengths (entity, attribute, graph, Ref target) come from SQL
  length() on the terms table, so no term is loaded to measure it;
- the transaction's metadata counts once per record;
- every string counts at JSON's worst-case escaping (6 bytes per byte),
  plus a fixed per-record overhead;
- counting stops once the bound passes the cap.
Limits gains page_max_bytes. The pre-pass refuses on rows first, so a
transaction over the row limit is never read.

The limit tests move to replicate_source_limits_tests.rs. New tests
check the bound against the real encoded records for each shape
(escaped strings and retracts, a Ref to a long IRI, bytes, metadata
repeated across records) and cover long entity IRIs and the page byte
cut. Each fails under its mutant.

Known limit: a term held only in an attached layer is measured by
resolving it once.

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

* fix(replicate-stream): count a string's bytes past an embedded NUL

SQLite's length() on a text value counts characters up to the first NUL,
so an IRI, actor, source or valid time holding a NUL undercounted the
transaction bound (706 against an encoded 6232 in the reviewer's
control). Every text length in tx_encoded_bound and term_len now reads
length(CAST(x AS BLOB)), which counts every byte.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant