draft: add pluggable age-v1 remote sync encryption#457
Draft
fujibee wants to merge 45 commits into
Draft
Conversation
…(proposed) Records the converged storage-axis design (independent codex pass + the Fugu design): sourced driver ABI (locked, no query/path/cursor leak), use-case contract with an opaque delivery cursor separate from read-state, and redis scoped to a message store (coordination split to a future axis). Decision-level; signatures live in the spec. Draft / proposed — subject to change.
…ient-scoped read (#203) Rewrite the storage-driver contract to the converged ADR 0003 design. - Messages-only: drop storage_teams/team_members and the team_joined/team_left events. The team registry stays file-based as a separate axis; a storage driver must implement the whole contract (no partial drivers). - Use-case ops (not a query language): storage_send / list_unread / mark_read_batch / watch_tip / watch_after / history (+ check/describe/init/ export/import/compact). - Opaque delivery cursor (§2.2): watch_tip returns the current tip; watch_after returns messages after an opaque cursor for the subscription pairs plus the advanced cursor. Core persists and re-passes the cursor and never parses or compares it — this removes the integer id > watermark assumption from core and lets one contract serve sqlite ints, UUIDv7, Redis stream ids, JSONL offsets. - Read-marking is recipient-scoped ((team, agent)) and idempotent; mark_read_batch now takes <team> <agent> <id>... Signatures live here, not in the ADR. Refs #51, ADR 0003.
…ew, #203) co1 static review (design unchanged, lock-blocking clarifications): - stdout framing: record-returning ops (send/list_unread/history/watch_tip/ watch_after) write data only and signal via exit code; only control ops (check/init/mark_read_batch/describe/compact) use §1.4 status-on-stdout. The watch_after trailing cursor record is data, not a status. - opaque cursor must be a single-line whitespace-free argv-safe token; drivers whose native position has unsafe chars encode (base64url). - watch_after's trailing cursor is the global tip the driver can resume from (like watch_tip), not the last matching message's cursor; advances on zero matches to avoid re-scans under off-subscription traffic. Contract locked. Refs #203, ADR 0003.
) Introduce the storage axis facade and its built-in sqlite driver implementing the locked contract (docs/spec/driver-interface.md §2, ADR 0003). Additive — call sites still use the legacy agmsg_sqlite/agmsg_db_path helpers until #206. - lib/storage.sh: agmsg_storage_driver (AGMSG_STORAGE_DRIVER > config > sqlite) and agmsg_storage_load (resolve via the axis-generic registry, ADR 0002; builtin always trusted, external gated by the opt-in trustfile; source once). - scripts/drivers/storage/sqlite.sh: the 12 contract ops over an append-only events log (message_sent / message_read), with a legacy-messages read path for back-compat. UUIDv7 ids (python3, urandom fallback). The delivery cursor is events.seq returned as an opaque decimal string; read-marking is recipient-scoped ((team, agent)) and idempotent; record-returning ops are data-only on stdout, control ops use the §1.4 status convention. - tests/test_storage_contract.bats: driver-agnostic contract tests (run against sqlite here; AGMSG_STORAGE_DRIVER swaps the backend for jsonl/redis later). Covers idempotent + recipient-scoped mark-read, watch_tip→watch_after cursor round-trip (advances on zero matches, no re-scan), and stdout framing. Refs #51, #204, ADR 0003.
…view, #204) Address co1's request-changes on #204 (all accepted): - Blocking 1 (ship-compat): the sqlite driver's list_unread / history now read the legacy `messages` table read-only, UNIONed with the events log (§2.4), so an existing store keeps its inbox + history when #206 cuts call sites onto the contract. Legacy rows are never migrated or mutated; marking a legacy id read records a recipient-scoped message_read event (the row's read_at is untouched) and the unread UNION excludes ids with a read event. storage_init now ensures the messages table exists so the UNION parses on fresh installs. - Blocking 2a (silent failure): record-returning ops run the `sqlite | tr` pipe under `set -o pipefail` (_sqlite_data), so a backend error returns non-zero instead of being masked by tr's exit 0. - Blocking 2b (§1.4 framing): storage_check emits `missing_deps` on stdout + exit 10; init / mark_read_batch / compact emit a status name on stdout. Spec §2 tightened: control ops *must* use §1.4 (was "may"); data ops stay data-only. - Non-blocking: list_unread / history records carry `type:message_sent`. - Contract tests: legacy unread/history + non-mutating legacy mark-read (sqlite-specific), data-op-failure → non-zero, cursor whitespace-free, cursor record is the final line, type field. 16/16 pass. Refs #204, ADR 0003.
…nological reads (co1 re-review, #204) - [required] storage_describe is a metadata op, not a control op: spec §2 drops it from the §1.4 control-op set (it already exits 0 with metadata-only stdout; adding a status name would break a metadata consumer). Control ops = check / init / mark_read_batch / compact. - (a) _sqlite_data no longer blanket-suppresses sqlite stderr: a backend error now surfaces on stderr (separate fd; stdout JSONL stays clean) so failures are debuggable, satisfying §2.1's 'non-zero + stderr'. - (b) list_unread / history order strictly by timestamp (events.at / messages.created_at), so legacy and event-log rows interleave chronologically rather than as two grouped blocks; both also storage_init first so a read on a fresh store can't error on missing tables. Contract 16/16. Refs #204, ADR 0003.
) #204 front-loaded the event-log substrate (events table, export/import, a basic storage_compact). #205 formalizes the canonical model and hardens compaction into a contract the jsonl driver (#207) can depend on — no new substrate, just spec + the cursor-safety guarantee pinned in sqlite. Spec (docs/spec/driver-interface.md): - §2.3 declares event-log schema v1 and its forward-compat rule: a projection MUST ignore unknown event types and unknown fields, so later revisions can add optional fields / event types without a breaking bump. That ignore-unknown rule IS the version marker; only removing/repurposing a field needs a major bump. - §2.7 turns compaction from a vague note into a 5-property contract: idempotent, state-preserving (unread/history/export-projection unchanged; only size shrinks), read-coalescing, monotonic, and — the load-bearing one — cursor-safe: a cursor issued before a compact must never skip a message after it; a driver whose physical cursor a log-rewrite would break (jsonl byte offset) must use a logical, compaction-stable cursor that at worst degrades to at-least-once. sqlite driver: - watch_tip / watch_after now issue the delivery cursor from the AUTOINCREMENT high-water (sqlite_sequence) instead of MAX(seq). A DELETE-based compaction can lower MAX(seq) by coalescing the tail message_read, but never the high-water — so an issued cursor stays valid and a fresh tip never regresses. sqlite is cursor-safe by construction: compaction only deletes redundant message_read, never message_sent. Tests: +5 (4 driver-agnostic: state-preserving, idempotent, cursor-safe, monotonic-tip; 1 sqlite: duplicate-read coalescing shrinks the log). Contract 21/21, full suite 407 ok / 1 (pre-existing watch flake #124). Refs #205, ADR 0003.
… tail-duplicate test (co1 #205 review) co1 static review of #205 confirmed the body sound and cursor-safety rock-solid (high-water survives DELETE/VACUUM — verified empirically, and no VACUUM exists in the tree). Two fixes: - [required] storage_export leaked a blank line for any unknown event type: its CASE returned NULL for a type it didn't recognize. list_unread/history/watch_after already filter with WHERE type=..., but export did not — breaking both the §2.3 forward-compat rule (#205's point) and JSONL cleanliness. export now carries the same WHERE type IN ('message_sent','message_read'). Added a forward-compat test: inject an unknown event type, assert export is clean JSONL with no leak and that list_unread/history are unperturbed. - [fold-in] the driver-agnostic cursor-safe / monotonic-tip tests passed for the wrong reason: mark_read_batch is write-idempotent, so they never created the tail duplicate rows whose deletion would regress a naive MAX(seq) tip — i.e. they'd pass even without high-water. Strengthened the sqlite shrink test to inject tail duplicates directly and assert the tip does NOT regress across compaction plus a pre-issued cursor still delivers. Verified it has teeth: reverting the cursor to MAX(seq) makes exactly this test fail while the agnostic ones stay green. Contract 22/22. Refs #205, ADR 0003.
…de (#206 phase 1) first phase moves only the READ/display paths; it is purely additive and keeps the suite green, because the facade's storage_list_unread / storage_history read the event log UNION the legacy messages table, so messages still written to the legacy table by send.sh stay visible. Deliberately deferred to the phase-3 atomic flip: mark-read and send. mark-read stays a legacy read_at UPDATE here — the read-state writers (inbox/check-inbox) and the legacy-read_at readers that have not migrated yet (watch-once, whose codex-bridge consumes an integer max_id cursor for stale-wake detection) are all coupled to read_at and must flip together with send and watch, or read-state splits (a half-migrated mark would be invisible to watch-once). Drawing the boundary at read-only keeps every commit consistent. - inbox.sh / check-inbox.sh: unread now comes from storage_list_unread (JSONL), parsed in one pass with sqlite's JSON funcs (no jq; cf. lib/hooks-json.sh). - history.sh: storage_history (events ∪ legacy). The ●/○ marker is derived by cross-referencing storage_list_unread per recipient (read-state is recipient-scoped, §2.3, so it is not carried on a history record). Team-wide history (no agent) now works via the widened contract below. - driver storage_history: <agent> is optional (omitted = whole team), and --limit returns the most RECENT N in chronological order (was: oldest N). spec §2.1 updated — both are additive (existing agent-scoped calls unchanged). - contract tests +2: team-wide history, and --limit recency/ordering. Contract 24/24, full suite 410 ok / 1 (pre-existing watch flake #124, fixed by PR #215). Refs #206, ADR 0003.
… truly optional (co1 #206 review) co1 request-changes on #206 phase 1 — two blocking gaps in the storage_history contract changes, both addressed: - [blocking 1] The newest-N --limit semantics lived only in the sqlite impl and a contract test, not the spec — a place a future jsonl/redis driver could diverge. spec §2.1 now states it: '--limit N selects the most recent N of the matching set; output is always time order, oldest→newest (the tail of the history, still chronological — never newest-first)', and a driver must honour both selection and ordering. - [blocking 2] The optional <agent> wasn't actually optional at the driver level: the old 'agent=$2; shift 2' misread 'storage_history <team> --limit N' (--limit taken as the agent) and was unsafe for 'storage_history <team>' alone. history.sh hid this by always passing an empty $2, but the contract was wrong. The parser now consumes a leading NON-flag arg as the agent (empty allowed = team-wide) and leaves --flags to the flag loop. Added a contract test: 'storage_history <team> --limit 1' returns exactly one team-wide record as pure JSONL (the flag is not swallowed as the agent). Non-blocking, tracked for the phase-3 / driver-switch checklist (co1): inbox/ check-inbox/history still gate on the agmsg_db_path file-existence check; a jsonl/redis active driver would early-exit when messages.db is absent, so that guard should move to driver-level empty-store semantics before a non-sqlite driver is enabled. Contract 25/25, full suite 411 ok / 1 (pre-existing watch flake #124, resolved by the upcoming rebase onto main / #215). Refs #206, ADR 0003.
The atomic cutover that makes the storage facade authoritative. After phase 1 (reads through the facade) this flips the writers and the live-delivery cursor so ALL message I/O is the event log; the legacy messages table is now read-only back-compat (§2.4). Done as one change because send (writer), mark-read (read-state writer), watch / watch-once (readers), and the codex bridge are all coupled through what was a single read_at + integer-id contract — splitting them would leave read-state or the delivery cursor half-migrated. Writers: - send.sh -> storage_send (a message_sent event). - inbox.sh / check-inbox.sh mark-read -> storage_mark_read_batch (a recipient-scoped message_read event; for a legacy id it records the read without mutating the row). Live delivery (opaque cursor, §2.2): - watch.sh watermark is now the driver's opaque cursor, not an integer id: storage_watch_tip seeds a fresh watcher; storage_watch_after streams new messages + a trailing cursor each poll. The #215 liveness guard is preserved untouched (session-liveness and delivery-position are independent). WATERMARK_FILE holds the token; the dead SQL WHERE_PAIRS builder is gone. - watch-once.sh counts unread via storage_list_unread; its max_id is now an OPAQUE token (greatest unread id), and codex-bridge.js compares it for equality only (stale-wake), never magnitude — parseMaxId/lastWakeMaxId/isStaleWake destringed. Correctness fix (driver): - storage_watch_after wraps its message scan + high-water read in one deferred WAL read transaction. Non-atomic, a row inserted between the two statements could advance the cursor past a message the scan never returned — a silent skip. The snapshot makes the emitted cursor never run ahead of the scan (§2.2 never-skip). - storage_send tries the INSERT first and only inits on failure (#114 pattern), instead of running PRAGMA WAL + CREATEs on every send — fixes lost rows under a concurrent fan-out. - rename-team.sh now rewrites the team in the events table too (best-effort), not just legacy messages, or a rename would orphan post-flip messages. Tests updated for the flip (behaviour is correct; the old assertions encoded the legacy path): delivery 120/124 send via send.sh (watch streams the event log, not direct messages inserts); watch #4 asserts the watermark against the storage tip (_storage_tip, the event high-water) not a messages id; storage fan-out counts events. Full suite 412 ok / 0. Tracked for the driver-switch checklist (co1, non-blocking): inbox/check-inbox/ history still gate on the messages.db file-existence check; move to driver-level empty-store semantics before a non-sqlite driver is active. Refs #206, ADR 0003.
…p-3 review) The shared comment claimed the trailing cursor always advances the watermark past the despawn control message at batch end. That holds for a broad watcher (which continues and reaches the cursor record), but the target watcher exits before the cursor record — it persists no cursor. Clarified: harmless because it drops the role and won't resume as it; a same-session resume re-reads the despawn (at-least-once) and re-tears-down idempotently. Comment-only. Refs #206.
A second storage backend implementing the same contract (docs/spec/driver- interface.md §2) over an append-only JSONL event log — the file IS the store. The driver-agnostic contract suite passes verbatim against it (AGMSG_STORAGE_DRIVER=jsonl), proving the contract is backend-portable. - Engine: jq is the zero-extra-dep default (declared by storage_check); duckdb is an OPT-IN, file-direct accelerator for the one hot anti-join (list_unread) on big logs — PoC crossover ~10-20k events (FINDINGS.md), below which jq's lack of startup cost wins, so the driver picks jq unless the log is past a byte threshold AND duckdb is on PATH. AGMSG_JSONL_ENGINE=jq|duckdb forces a choice. No daemon. - Delivery cursor (§2.2) is a LOGICAL position — the ordinal count of message_sent events, not a byte offset. Compaction only coalesces message_read (never removes or reorders message_sent), so an ordinal survives a log rewrite; a byte offset would not (§2.7 cursor-safe). The single file read per query is its own snapshot, so the trailing cursor never runs ahead of the rows returned (no skip). - All log mutations serialize behind a portable mkdir lock; reads take a file read as their snapshot. UUIDv7 ids, no counter file. compact rewrites the log (message_sent order preserved) via a temp + atomic mv. - duckdb path reads explicit VARCHAR columns (never read_json_auto, whose type inference would parse as a TIMESTAMP and drop the canonical ISO-8601 T/Z) — verified byte-identical to the jq path against duckdb 1.1.3. Tests: the 4 sqlite-specific contract tests (legacy table / direct events injection / high-water) skip for non-sqlite drivers; the broken-store test corrupts whichever store the active driver reads. jsonl passes all driver-agnostic tests; sqlite stays 25/25. Refs #207, ADR 0003, epic #51.
…file gate (#207) inbox / check-inbox / history gated on a literal `[ -f messages.db ]`, so under a non-sqlite active driver they would always early-exit (no messages.db exists) even with a populated events.jsonl. Replace the file gate with a new contract op, storage_store_exists: the DRIVER reports whether its store is present (sqlite db file, jsonl events.jsonl, a Redis key, …) WITHOUT creating one — preserving the read-only 'don't materialize an empty store on a mere inbox/history read' behaviour. - spec §2.1: storage_store_exists added (additive — existing ops unchanged). - sqlite + jsonl drivers implement it; the three read call-sites use it. - contract test: store_exists true after init; full e2e under AGMSG_STORAGE_DRIVER= jsonl verified (storeless inbox says 'not initialized' and creates nothing; after sends, inbox/history/mark-read all work on the jsonl backend). This is co1 step-3 non-blocking item #1 (needed before a non-sqlite driver is active). Refs #207, ADR 0003.
…x id (#207) co1 step-3 non-blocking item #2. watch-once reported max_id as the lexicographic max of the unread ids. With UUIDv7 (sqlite) that tracks recency, but a backend whose ids are not recency-ordered (jsonl) could change the unread SET without changing the max — the codex bridge's stale-wake guard would then miss real progress. Replace it with a cksum DIGEST of the whole sorted unread-id set, which changes whenever the set changes on any backend. Still an opaque equality-only token for the bridge (verified: set-change -> token-change, order-independent, whitespace-free for the bridge's max_id=(\S+) parse); cksum is POSIX, no shasum dep, and the bridge only compares it within one session. Also: watch-once gates its poll on storage_store_exists (driver-level) instead of [ -f messages.db ], so it works under the jsonl driver too; dropped the now-unused DB var. watch-once 6/0, codex-bridge 16/0. Refs #207, ADR 0003.
Adds a non-required `storage-jsonl` job (ubuntu + macos) that runs the SAME driver-agnostic contract suite under AGMSG_STORAGE_DRIVER=jsonl, so the contract's backend-portability is verified continuously and jsonl can't silently rot as the contract evolves. Both userlands are covered because the jsonl driver shells out to jq/sed/sort/cksum/paste, which differ between GNU and BSD. jq + sqlite3 are present on the runners (sqlite3 is still used by the suite's JSON-validation assertions); duckdb is absent, so the jq engine path is the one exercised. Non-required for now — promote once stable. Refs #207, ADR 0003.
…review) Same 'report success on a backend failure' class co1 caught in the sqlite driver. - [Required] storage_mark_read_batch ignored _jsonl_with_lock's exit and always echoed ok, so a lost write lock / failed append on the inbox read hot path would look read while no message_read landed — a re-delivery loop / stale wake. It now returns runtime_error + non-zero on failure (§1.4 control-op framing). New jsonl-specific test holds the lock (AGMSG_JSONL_LOCK_TRIES caps the retry) and asserts the failure surfaces, never a false ok. - [Required] storage_watch_tip had a '|| echo 0' that turned a corrupt-log jq error into a fresh tip of 0 (silent). Dropped it: an empty log already yields 0 with exit 0, and a corrupt one must fail non-zero (§2.1 data-op framing). The broken-store contract test now asserts watch_tip fails too, not just list_unread. - [duckdb parity] the duckdb anti-join inlined team/agent/log into SQL string literals; a team/agent with an apostrophe (allowed by validate.sh) would break or misbehave on the duckdb path while the jq path stayed correct. SQL-escape them — verified jq == duckdb byte-identical for an apostrophe team against duckdb 1.1.3. Refs #207, ADR 0003.
…207 residual) co1's non-blocking residual on the otherwise-locked #207: _jsonl_mark scanned the log for existing reads with a trailing 2>/dev/null and ignored jq's exit, so a corrupt log would yield an empty 'existing' set and the mark would append reads for every id as if new (a swallowed failure, the same class as the two required fixes). The scan now propagates jq's non-zero exit, which storage_mark_read_batch turns into runtime_error. New jsonl test pins it (corrupt log -> mark fails, never a false ok). Refs #207, ADR 0003.
…221 CI) The #221 'storage contract (jsonl, macos-latest)' leg failed while ubuntu passed. macOS ships bash 3.2 (the suite runs under it); the jsonl driver failed to source there — its LAST function, _jsonl_compact_do, was never defined ('command not found' at compact time), so the compaction contract tests failed. Crucially `bash -n` reports the file clean; only an actual source surfaces the error, which is why local 5.x runs were green. Two constructs desync the bash 3.2 parser (each fine alone, but they break the parse of the rest of the file): - A multi-line single-quoted jq program in _jsonl_compact_do. Reduced to a single line (semantically identical; the (team,agent,msg_id) key now uses join(' ')). Added a NOTE so future jq here stays one-line. - The inline, apostrophe-laden duckdb SQL string. Moved the query to a companion data file, scripts/drivers/storage/jsonl-unread.duckdb.sql, read at runtime and filled by literal bash substitution (__LG__/__TL__/__AL__, all pre-SQL-escaped) — so no SQL text is bash-parsed at all. The driver locates it via _JSONL_DRIVER_DIR (captured from BASH_SOURCE at source time); install ships it via the existing recursive scripts/ copy. Verified: the full file now defines every function and the jsonl contract suite (incl. all compaction tests) passes under bash 3.2 + LC_ALL=C (the CI macOS condition); duckdb path still byte-identical to jq; sqlite full suite 429 ok / 0. Refs #207, #221.
…#221) co1 caught that the single-lining of _jsonl_compact_do changed the duplicate-read key to a space-joined string, which is NOT semantically identical: team/agent names may contain spaces (validate.sh allows them), so distinct tuples collapse to one key — e.g. (team='a b', agent='c', msg_id='1') and (team='a', agent='b c', msg_id='1') both flatten to 'a b c 1'. With legacy decimal ids repeating across teams, compact could then drop a genuinely-distinct message_read and break state-preservation. Key is now the collision-free JSON encoding of the tuple ([.team,.agent,.msg_id] | tojson), matching the sqlite driver's GROUP BY (team, agent, msg_id) semantics. Still a single line (bash 3.2 parse intact). New jsonl test injects two reads whose tuples only collide on a space-join and asserts compaction keeps both. Verified under bash 3.2 + LC_ALL=C: parse OK, contract incl the new test green (jsonl 29/0), sqlite contract unchanged. Refs #207, #221, ADR 0003.
, #87) rename.sh and rename-team.sh interpolated team/agent names directly into their messages/events UPDATE string literals, escaping nothing. A name with a single quote (validate.sh only blocks path traversal) broke the UPDATE and was an injection surface that could widen the WHERE predicate. - add a driver-agnostic agmsg_sqlesc helper (same semantics as the sqlite driver's _sqlite_lit / storage_send escaping) - escape every interpolated value in the rename / rename-team UPDATEs - rename-team's config read switches from `.param set` (whose dot-command tokenizer does not honour SQL '' escaping, so apostrophe content breaks the binding) to the readfile() idiom join.sh already uses, so a quoted team name round-trips end to end - regression tests: a quoted team migrates only its own messages (no predicate widening); the rename messages UPDATE stays scoped rename.sh still interpolates agent names into json *paths* ($.agents.<name>) for the config lookup, so quoted-agent rename stays gated there pending the separate json-path handling decision (#87 follow-up); the UPDATE escaping is defense-in-depth that also protects legacy rows.
Post-rebase fallout from the storage-axis flip (send.sh now writes storage_send -> events, not a direct messages INSERT): several test assertions that predate the flip still queried the legacy messages table directly, so they silently stopped verifying real behavior instead of failing. - test_messaging.bats / test_storage.bats: two --force / roster / runtime-lock tests counted rows in `messages`; a send that landed in `events` made the count permanently 0, whether or not the send actually worked. - test_delivery.bats: two watch.sh subscription tests sent through an unregistered "sys"/"system" sender, which #355's roster check now rejects; added --force since the sender identity is orthogonal to what these tests verify. - test_inbox.bats: `unread_count` computed `SELECT COUNT(*) FROM messages WHERE ... read_at IS NULL`, which is now always 0 (mark- read no longer touches read_at post-flip) -- both race-condition regression tests for the display/mark barrier were passing for the wrong reason (0 == 0), not because the guarantee held. Rewrote it to count via storage_list_unread, which makes both barrier tests meaningful again.
…nto dogfood/stage1-sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This dogfood PR stacks on Draft PR #450 and keeps the remote-sync work unmerged. It adds a shared cipher profile registry with
noneas the default and a standardage-v1seal/open implementation. Private identities stay in the engine open path; storage drivers receive only public recipient manifests.The implementation atomically publishes each randomized wire ID with its complete envelope, preserves byte-exact retries, supports explicit quarantine reprocessing without rewinding transport, and fails closed on epoch rotation until the normative multi-writer fence and chain-verification workflow is automated.
It also adds the approved 19-vector contract suite against age v1.3.1, a dedicated CI job using the standard age implementation, and ADR 0006 documenting the cipher-neutral single server schema and local-first H8 boundary.
Verification
node --test tests/remote_sync_engine.test.mjsbats --print-output-on-failure tests/test_remote_sync.batsAGMSG_AGE_BIN=/path/to/age node --test tests/sync_cipher.test.mjsAGMSG_AGE_BIN=/path/to/age bats --print-output-on-failure tests/test_sync_cipher.batsAGE_BIN=/path/to/age node docs/spec/vectors/verify-age-v1-vectors.mjsThis PR is intentionally Draft and must remain unmerged while the dogfood track is under review.