Skip to content

draft: add pluggable age-v1 remote sync encryption#457

Draft
fujibee wants to merge 45 commits into
mainfrom
dogfood/age-v1-profile
Draft

draft: add pluggable age-v1 remote sync encryption#457
fujibee wants to merge 45 commits into
mainfrom
dogfood/age-v1-profile

Conversation

@fujibee

@fujibee fujibee commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

This dogfood PR stacks on Draft PR #450 and keeps the remote-sync work unmerged. It adds a shared cipher profile registry with none as the default and a standard age-v1 seal/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.mjs
  • bats --print-output-on-failure tests/test_remote_sync.bats
  • AGMSG_AGE_BIN=/path/to/age node --test tests/sync_cipher.test.mjs
  • AGMSG_AGE_BIN=/path/to/age bats --print-output-on-failure tests/test_sync_cipher.bats
  • AGE_BIN=/path/to/age node docs/spec/vectors/verify-age-v1-vectors.mjs

This PR is intentionally Draft and must remain unmerged while the dogfood track is under review.

fujibee added 30 commits July 20, 2026 15:24
…(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.
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