Skip to content

feat(bindings): a replica through the binding facade and the browser Database - #798

Merged
scbrown merged 23 commits into
mainfrom
wu/s24-2-applier-s1b-bridge-20261024
Oct 24, 2026
Merged

scbrown merged 23 commits into
mainfrom
wu/s24-2-applier-s1b-bridge-20261024

Conversation

@scbrown

@scbrown scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #796 (shared applier S1). Review scope: the last commit only (feat(bindings)). It stays a draft until #796's base merges.

The shared applier joins the binding facade, so the in-page store reaches it through the one engine:

  • Engine::replica_apply_frame(event, data, now_ms), replica_progress(), replica_freshness(now_ms).
  • The wasm Database exports them to JavaScript as replicaApplyFrame / replicaProgress / replicaFreshness. They return JSON text in the contract Outcome serializes to (tagged by kind). A protocol refusal is a returned outcome; only a local store failure throws.
  • Read-only replica: after the first replica call, local writes through invoke (knot, episode, set, retract, shapes load/remove) are refused before dispatch with read_only_replica / not_started. Reads keep working.
  • No silent fork: the applier writes what the source already accepted, so local write validation is suspended for the applier's own writes and restored afterwards. A test proves both halves: the adopted shape refuses the data when written locally, and the replica applies the same data from the feed.

Local: bindings:: 9/0 (2 new); clippy default clean; explorer cargo clippy --target wasm32-unknown-unknown -D warnings clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BkdpDkAkbTvXvNbNacVpJb

scbrown and others added 9 commits October 23, 2026 21:42
…ls 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
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
… 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
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
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
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
…d 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
…tract

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
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
@scbrown
scbrown force-pushed the wu/s24-2-applier-s1b-bridge-20261024 branch from 1f99ba5 to 7bcb831 Compare October 24, 2026 06:59
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Restacked onto #796's 569130d (the gap fix). New head 7bcb831; same single commit, no content change. Replica tests: 19 passed. The browser bundle crate (wasm/explorer) builds for wasm32.

scbrown and others added 2 commits October 24, 2026 05:55
…e 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
…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
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Restacked onto S1 head c34da2b (#796); bridge commit now 4a782fb, content unchanged. Local: bindings/applier/knowledge lib tests 35 passed / 0 failed. Draft until the S1 stack lands.

@scbrown
scbrown force-pushed the wu/s24-2-applier-s1b-bridge-20261024 branch from 7bcb831 to 4a782fb Compare October 24, 2026 11:02
scbrown and others added 2 commits October 24, 2026 07:54
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
…n 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
scbrown and others added 2 commits October 24, 2026 07:55
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
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Restacked onto #796 3442c9d (the digest rule) by a merge, now at 25a7342. Also fixed TS in src/bindings/tests.rs, which was gated on shacl but used in every build (no-default test build E0425).

Local checks: lib 2799 passed / 0 failed; clippy clean on no-default, default and shacl; knowledge test OK. Still a draft.

…0261024

# Conflicts:
#	docs/knowledge/repository-knowledge.json
#	src/server/feed_stream_tests.rs
#	src/store/changes_replay_tests.rs
…0261024

# Conflicts:
#	src/server/feed_stream.rs
#	src/share_completeness.rs
@scbrown
scbrown marked this pull request as ready for review October 24, 2026 15:30
scbrown and others added 2 commits October 24, 2026 12:17
…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
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGES at 8e13639

R1 P2 — first replica promotion accepts a store containing local facts, so its source cursor can accompany data that the source never supplied. Engine::replica_apply_frame unconditionally opens Applier on its existing owned store and marks it read-only; there is no data-empty or verified-bootstrap admission check. The source feed then adds source data without removing unrelated existing facts.

Counterexample input: create an Engine, adopt a shape, write a valid local-only record through invoke, then apply a complete source frame containing a different record. The code path permits Applied/progress while retaining the local-only record. The author has confirmed that S1 requires no live facts (shapes/configuration allowed), or a verified bootstrap; the client overlay is a separate API. Add that refusal at the shared-applier boundary so every consumer gets it, before facts or cursor change. Pair the dirty-store refusal with a shapes-only/empty-store positive and retain normal replay/progress controls. Do not silently delete local data.

ONE nonauthor review of all three own-change paths (binding facade, tests, wasm bridge); inherited eleven-path Q796 parent is exact reviewed258c ancestor. Current native T2 d211 PREPARED after exact-head complete green; preparation only, no approval recorded. The six write-operation gates and JSON-text integer transport were inspected. A private public-library counterexample was prepared, but its Cargo attempt was interrupted to yield to already active builds; HOME157+5 CLEAN, no runtime test result claimed. This finding is source-level and author-confirmed, not a fabricated executed falsifier. No source edit, production replica, browser/adoption claim, approval or merge. Tim retains SAME repair-delta custody after author repair/fresh green/current native gate; Q803 remains behind798.

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
…bridge-20261024

# Conflicts:
#	src/applier.rs
#	src/applier_tests.rs
@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Repair for review 6387176506 (CHANGES at 8e13639), new head be0ba65. It adds one fix commit (dcd63fe), plus a merge of main now that #796 has landed.

Finding. replica_apply_frame promoted any Engine on its first call, and the applier tailed the feed into a store that already held local facts. Such a replica equals no source, while its cursor and digests claim it does.

Rule (as confirmed on the review): a store may become a replica only if it already follows a source, or if it holds no facts. Shapes and settings are not facts.

  • Applier::new_replica_refusal(store), in src/applier_start.rs, states that rule.
  • The applier refuses a tail into such a store: Refused, nothing written, no cursor.
  • The binding checks before promoting. The frame is refused, and the Engine stays an ordinary writable store.

Paired controls:

  • applier::tests::a_store_holding_local_facts_cannot_start_a_replica fails with the applier gate disabled.
  • bindings::tests::a_store_holding_local_facts_is_not_promoted_to_a_replica fails with the binding gate disabled. The promotion happens and the later local write is refused as read_only_replica.
  • Both pass with the gates restored.

Size gate. record_to_datum and value_of move unchanged to src/applier_record.rs to keep applier.rs under 500 code lines. git diff --color-moved shows the move.

Merge of main. src/applier*.rs on main are byte-identical to the S1 branch (258cba7), so the add/add conflicts resolve to this branch's side.

Local results:

  • default suite: 3188 passed, 0 failed;
  • --features shacl,onnx,server --bin quipu-server: 236 passed, 0 failed;
  • clippy on default, no-default and shacl: nothing new;
  • fmt, the size gate and the knowledge witnesses (12/12) are clean;
  • wasm32 cargo check: clean.

#803 will be refreshed onto this head after review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BkdpDkAkbTvXvNbNacVpJb

@scbrown

scbrown commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Q798 SAME repair SOURCE APPROVE at be0ba65. Original dirty-replica admission CHANGES retained and resolved.

Independent original8e control actually returns Applied with local facts retained (2PASS/1FAIL); repair5/5 public API controls PASS: dirty Engine refusal, unchanged facts/progress and writable Engine, shapes-only admission, shared Applier dirty-store refusal, fresh/read-only frame handling and large i64. HOME157 watched paths +5 UID resources CLEAN. Private fixture AsOf compile error preserved then corrected; reviewed source unchanged. Decoder relocation byte-identical; full proposal replay/inverse EXACT against4b. Current2733 native T2 c009 PREPARED=true after exact-head complete hosted green; accepted SAME content3fef34f4942e92e7332d9e1547acf95ff0c191d20638b5ca245346188520cb67 verified; diff-bound merge required. Source review only; no production replica/bootstrap activation.

@scbrown
scbrown merged commit d68a89a into main Oct 24, 2026
24 checks passed
@scbrown
scbrown deleted the wu/s24-2-applier-s1b-bridge-20261024 branch October 24, 2026 18:49
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