From a689c3282c9ef5b11183f89feebb994192a2e69a Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 15:08:04 -0700 Subject: [PATCH 1/6] test(docs): check that relative document pointers resolve Nothing checked them. docs/spec/vectors/age-v1-vectors.json ships a profile_document pointing at a path that does not exist, and the vectors are what a second implementation reads first. On this commit the check is RED and names eleven pointers: five in ADR 0003, which 75b8b1c moved out of docs/adr/ref/ without adjusting its relative depth; five from the age-v1 profile to its own conformance vectors, which sit outside ref/ while the profile does not; and the vectors' own pointer back. The following commits close all eleven. Both halves refuse to pass on an empty match, so a matcher that stops matching, or a renamed JSON field, reports itself instead of going quiet. --- tests/test_doc_links.bats | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_doc_links.bats diff --git a/tests/test_doc_links.bats b/tests/test_doc_links.bats new file mode 100644 index 00000000..626546fa --- /dev/null +++ b/tests/test_doc_links.bats @@ -0,0 +1,109 @@ +#!/usr/bin/env bats + +# Relative pointers between documents are load-bearing and nothing was checking +# them. `docs/spec/vectors/age-v1-vectors.json` has shipped a +# `"profile_document": "../age-v1-profile.md"` that resolves to a path which +# does not exist, and the conformance vectors are what a second implementation +# reads first. A moved or renamed document breaks the same way and silently. +# +# Scope is deliberately narrow: does the target exist. Anchors are stripped +# rather than verified, because a missing heading and a missing file are +# different failures and only the second one makes a reader follow a dead path. + +setup() { + ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +@test "every relative link in a tracked markdown file resolves" { + run python3 - "$ROOT" <<'PY' +import os, re, subprocess, sys + +root = sys.argv[1] +files = subprocess.run( + ["git", "-C", root, "ls-files", "*.md"], + capture_output=True, text=True, check=True).stdout.split() + +# Inline links only. A bare `(text)` in prose is not a link, so the opening +# `](` is required, and the target must not be a URL, a mail address, or a +# pure anchor. +link = re.compile(r"\]\(\s*([^)\s]+?)\s*(?:\s+\"[^\"]*\")?\)") +skip = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//|#)") + +broken = [] +checked = 0 +for rel in files: + path = os.path.join(root, rel) + with open(path, encoding="utf-8") as fh: + text = fh.read() + # Fenced code blocks hold example commands and placeholder paths that are + # not links to anything in this tree. + text = re.sub(r"^```.*?^```", "", text, flags=re.S | re.M) + for target in link.findall(text): + if skip.match(target): + continue + target = target.split("#", 1)[0] + if not target: + continue + # docs/adr/template.md shows the supersede form with the number left + # blank; that is a slot to fill, not a pointer to follow. + if "XXXX" in target: + continue + checked += 1 + resolved = os.path.normpath(os.path.join(os.path.dirname(path), target)) + if not os.path.exists(resolved): + broken.append(f"{rel} -> {target}") + +print(f"checked {checked} relative links in {len(files)} markdown files") +if broken: + for b in broken: + print("BROKEN:", b) + sys.exit(1) + +# A tree this size always has relative links; zero would mean the matcher +# stopped matching rather than that everything resolved. +if checked == 0: + print("BROKEN: matched no relative links at all -- the matcher is blind") + sys.exit(1) +PY + echo "$output" + [ "$status" -eq 0 ] +} + +@test "every document pointer inside the conformance vectors resolves" { + run python3 - "$ROOT" <<'PY' +import json, os, subprocess, sys + +root = sys.argv[1] +files = [f for f in subprocess.run( + ["git", "-C", root, "ls-files", "docs/spec/vectors/*.json"], + capture_output=True, text=True, check=True).stdout.split()] + +broken = [] +checked = 0 +for rel in files: + path = os.path.join(root, rel) + with open(path, encoding="utf-8") as fh: + doc = json.load(fh) + target = doc.get("profile_document") if isinstance(doc, dict) else None + if not target: + continue + checked += 1 + resolved = os.path.normpath(os.path.join(os.path.dirname(path), target)) + if not os.path.exists(resolved): + broken.append(f"{rel} -> {target}") + +print(f"checked {checked} profile_document pointers in {len(files)} vector files") +if broken: + for b in broken: + print("BROKEN:", b) + sys.exit(1) + +# The generator writes this field, so its absence means the field was renamed +# and this check went quiet rather than that it passed. +if checked == 0: + print("BROKEN: no profile_document pointer found -- the field moved") + sys.exit(1) +PY + echo "$output" + [ "$status" -eq 0 ] +} From 026634cc12073d9f12a61ba744214fd9566d9b5c Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 15:12:44 -0700 Subject: [PATCH 2/6] docs(server): make v1.md the record of the protocol the server serves It carried a SUPERSEDED banner, which is an ADR mechanism: ADRs are immutable and get superseded, specs get edited (CONTRIBUTING.md). Marked rather than corrected, it left the contract with four records - this file, the remote-sync design, a spec under ref/ that says not to cite it, and a comment in remote-sync.mjs naming errors.ts the definition because this file was dead. Rewritten from the implementation. The endpoint list was eight, of which POST /v1/pairing/exchange and POST /v1/credentials//revoke do not exist and four that do were absent; it is now the ten server/src/app.ts registers. The pairing and per-device-credential section described a removed model and is replaced by the four registration and lookup endpoints, taken from connectSchema, resolveTeamsByName and getTeamSnapshot. Authorization: Bearer was required on everything but health; the server's only mention of that header is redacting it from logs. The reason it carries no credential, and the two consequences that rest on it - a repeat connect writing nothing, and the network being the trust boundary - are stated where a reader meets them. The error table listed six codes for the removed model and omitted three the server emits. It is now the set derived from every ProtocolError construction, and says what it does not cover rather than leaving 502/503/504 looking protocol-defined. --- scripts/internal/remote-sync.mjs | 6 +- server/spec/v1.md | 269 ++++++++++++++----------------- 2 files changed, 128 insertions(+), 147 deletions(-) diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 893650b5..5950f9bf 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -1468,9 +1468,9 @@ async function send(config, path, init, authHeaders) { // The error code. // // THE PROTOCOL SHAPE IS THE NESTED ONE: `{ error: { code, message, details } }`, -// built by `errorBody()` in the reference server (server/src/errors.ts) — which -// is the definition, since server/spec/v1.md is marked SUPERSEDED. That is what -// a new implementation should emit and what a reader here should take as the +// specified in server/spec/v1.md under "Common response fields" and built by +// `errorBody()` in the reference server (server/src/errors.ts). That is what a +// new implementation should emit and what a reader here should take as the // contract. // // The bare-string branch is a BRIDGE, not a second valid shape. The hosted edge diff --git a/server/spec/v1.md b/server/spec/v1.md index 627644d4..320bc721 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -1,37 +1,28 @@ # agmsg Remote Storage HTTP API v1 -> **SUPERSEDED — do not build to this document.** It records the protocol as it -> stood before `/v1/connect`, when a device exchanged an operator-issued pairing -> token for a bearer credential. That model is gone: nothing issues a credential -> and the data plane takes its team from a header, because reaching the server is -> the permission. -> -> The difference is systematic rather than a few stale paragraphs. This document -> declares eight endpoints including `POST /v1/pairing/exchange` and -> `POST /v1/credentials//revoke`, neither of which exists, and requires an -> `Authorization: Bearer` header on everything but health. Meanwhile `/v1/connect` -> and the `/v1/teams` lookups the server actually serves appear nowhere in it. -> Correcting it section by section would leave a document wrong in a new way, so -> it is marked rather than patched; rewriting it is its own task. -> -> Normative today: [`docs/design/remote-sync.md`](../../docs/design/remote-sync.md) -> for the model, and -> [`docs/spec/ref/stage-1-remote-sync.md`](../../docs/spec/ref/stage-1-remote-sync.md) -> for the sync contract. Kept here as the record of what the pairing design was -> and why it was replaced. - -Status: superseded — see the notice above. Was: draft for protocol review. - -This document was the normative contract between agmsg storage drivers and the +Status: current. + +This document is the normative contract between agmsg storage drivers and the self-hosted reference server. The words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described by RFC 2119. +The reasoning behind the model — why remote synchronization is local-first, and +why reaching the server is the permission — is +[the remote-sync design](../../docs/design/remote-sync.md). The driver-side +durability contract those endpoints are consumed through is +[the Stage-1 synchronization specification](../../docs/spec/stage-1-remote-sync.md). +Neither restates the endpoint shapes below; this document is the only record of +them. + ## Scope and conventions -V1 defines eight endpoints: +V1 defines ten endpoints. The first four register a team and let a second +machine find one; the rest are the data plane: -- `POST /v1/pairing/exchange` -- `POST /v1/credentials//revoke` +- `POST /v1/connect` +- `GET /v1/teams?name=` +- `GET /v1/teams/` +- `GET /v1/teams//messages?after=` - `POST /v1/messages` - `GET /v1/messages?after=` - `GET /v1/members` @@ -62,19 +53,33 @@ Agmsg-Protocol-Version: 1 ``` `GET /v1/health` does not require either request header because it is also the -version-discovery and orchestration probe. `POST /v1/pairing/exchange` requires -the version header but not the team header because the opaque pairing token is -already team-scoped. A missing, malformed, or unsupported version on another -endpoint returns `426 unsupported-protocol-version` and the server's supported -versions. The header version MUST equal the URL path version. - -Except for health and pairing exchange, the reference profile requires an -`Authorization: Bearer ` header. The credential MUST -be bound to the immutable `Agmsg-Team-ID`; the team header alone is never -authorization. Credentials MUST NOT be embedded in the team header or URL. -Alternative deployments MAY use a different authentication mechanism, but -MUST preserve the team binding, error, and credential-isolation semantics in -this document. +version-discovery and orchestration probe. The four registration and lookup +endpoints require the version header but not the team header: `POST /v1/connect` +carries the team it is registering in its body, and the `/v1/teams` routes carry +it in the query or the path. A missing, malformed, or unsupported version on +another endpoint returns `426 unsupported-protocol-version` and the server's +supported versions. The header version MUST equal the URL path version. + +There is no per-request credential, deliberately. Reaching the server is the +permission, the way reaching the filesystem is the permission for a local team. +The trust boundary is the network the server sits on — LAN, tailscale, VPN, or +loopback for self-host — not a secret carried in the request. The team comes +from `Agmsg-Team-ID` alone on the data plane, and from the body, query, or path +on the four endpoints above. Team-scoped operations reject a team that does not +exist with `404`, so an unknown id cannot reach another team's data; knowing a +team id IS enough to read that team, which the design accepts for this minimum +rather than overlooks. + +Two consequences follow and are load-bearing rather than incidental. A repeat +`POST /v1/connect` for a team id that already exists writes nothing at all and +is refused (see that endpoint), because knowing an id is not permission to +change a team someone else registered. And a server MUST NOT be exposed to a +network whose reachability it is not willing to treat as authorization. + +`Authorization` is not part of this protocol. A server MAY receive one from a +client predating this version and MUST NOT log it. Alternative deployments MAY +add an authentication mechanism in front, but MUST preserve the team binding and +error semantics in this document. Timestamps MUST use the exact UTC form `YYYY-MM-DDTHH:MM:SS.ffffffZ`, with six fractional digits and no leap seconds. Sequence values in JSON and query @@ -270,9 +275,9 @@ is ordering/diagnostic metadata only; sequence remains authoritative. ## `GET /v1/capabilities` -This authenticated, team-scoped endpoint advertises the current write policy. -It is deliberately separate from unauthenticated health and says nothing about -which ciphers a particular client can read. +This team-scoped endpoint advertises the current write policy. It is +deliberately separate from health, which is not team-scoped, and says nothing +about which ciphers a particular client can read. Independently of server policy, every client sync configuration MUST durably pin an append-only local security history to its `(server_instance_id, team_id)` @@ -379,117 +384,93 @@ effective write set for `next_sequence_boundary`. A v1 client supports only envelope. A later POST policy race may still return `403`, which the client handles as specified below. -## Pairing and per-device credentials +## Team registration and lookup -The reference onboarding profile bootstraps one independently revocable device -credential from an operator-issued pairing token. The token is an opaque, -cryptographically random, team-scoped exchange code. Clients MUST NOT parse it, -derive an endpoint from it, or treat it as the long-lived credential. An -operator supplies the endpoint separately. Each token expires 15 minutes after -issuance and can successfully exchange exactly once. Operators issue one token -per device; sharing the resulting credential between devices is forbidden. +These four endpoints carry no `Agmsg-Team-ID` header. They name their team in +the body, the query, or the path, and none of them carries a credential for the +reason given under Scope and conventions. -The client exchanges the token with: +### `POST /v1/connect` + +Registers a team the client already owns. The client mints `team_id`; the server +never allocates one, so a machine that has a local team can publish it without +first asking for an identity. ```http -POST /v1/pairing/exchange +POST /v1/connect Agmsg-Protocol-Version: 1 Content-Type: application/json -``` -```json { - "token": "agmsg_pair_opaque-value" + "team_id": "", + "team_name": "", + "members": [{"member_id": "", "name": ""}], + "cipher_profile": "none" } ``` -The body is strict: `token` is the only field. The endpoint does not require or -use an `Agmsg-Team-ID` or bearer credential because the code already selects -and authorizes one team. A successful exchange creates the credential and -consumes the token in one database transaction, returns `Cache-Control: -no-store`, and has this shape: +`members` holds at most 1000 entries. `cipher_profile` is `none` or `age-v1` and +is OPTIONAL: it records what the connecting machine declares this team uses, so +a client predating the field still connects and the team is then recorded with +no declaration rather than a wrong one. It is not a secret — naming a profile +reveals nothing about plaintext, and the server already sees the per-envelope +`cipher` on every message it stores. What it could not otherwise see is a team +that has sent none yet. + +A successful response is `Cache-Control: no-store` and is the same capability +snapshot `GET /v1/capabilities` returns, so a client needs no second request to +learn the team's accepted envelope versions and write-allowed ciphers. + +A `team_id` that is already registered is refused with `409 team-already-exists` +and **nothing is written — not even a missing `cipher_profile`**. This is a +uniqueness conflict, the same shape as a refused non-fast-forward push, and not +an authorization decision. It has to hold under concurrency, so the primary key +is the sole arbiter: two simultaneous connects for one id cannot both observe an +absent row and both insert. The refusal is what keeps a route with no credential +safe, because knowing a `team_id` is not permission to change a team someone +else registered — and a `cipher_profile` of `none` fixed first by a stranger is +the dangerous direction. + +### `GET /v1/teams?name=` + +The other half of connect: a second machine takes a team it does not have, and a +human should not have to carry a UUID between them. + +A team name is NOT unique — only `team_id` is — so this endpoint always answers +with a list and the caller chooses. One match is the ordinary case, never a +guarantee, and a client MUST NOT treat a single-element list as identification. ```json { "protocol_version": 1, - "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", - "remote_team_id": "018f3f7e-0000-7000-8000-000000000001", - "remote_team_name": "example-team", - "credential_id": "018f3f7e-0000-7000-8000-000000000020", - "credential": "agmsg_credential_opaque-value", - "capabilities": { - "protocol_version": 1, - "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", - "team_id": "018f3f7e-0000-7000-8000-000000000001", - "team_name": "example-team", - "min_available_seq": "0", - "current_seq": "0", - "next_sequence_boundary": "1", - "accepted_envelope_versions": [1], - "write_allowed_ciphers": ["none", "age-v1"], - "policy_revision": "0", - "effective_from_seq": "1", - "max_blob_bytes": "1048576", - "policy_history": [ - { - "policy_revision": "0", - "effective_from_seq": "1", - "accepted_envelope_versions": [1], - "write_allowed_ciphers": ["none", "age-v1"] - } - ] - } + "server_instance_id": "", + "team_name": "", + "teams": [ + {"team_id": "", "team_name": "", + "registered_at": "", "current_seq": ""} + ] } ``` -`credential_id` is a canonical UUIDv7, stable and non-secret, and identifies -this device binding for listing and revocation. `credential` is the distinct, -long-lived opaque bearer secret. The nested `capabilities` value is exactly the -canonical `GET /v1/capabilities` document from the same database snapshot, -including all common binding fields. The outer IDs MUST equal the corresponding -nested IDs. The server MUST serialize this snapshot with policy mutation so it -cannot combine a team row from one revision with policy history from another. - -Before the request, the client has made no local binding change. After a `200`, -it MUST verify the outer and nested binding and capability invariants, then -atomically persist the bearer secret in an engine-owned private credential -store and the non-secret binding separately. A POSIX file credential store MUST -be mode `0600`; team configuration and logs MUST NOT contain the token or -secret. If persistence fails, the client MUST NOT report a connected state. -Because the server never stores plaintext credentials, a response lost after -commit cannot be recovered with the consumed token. The operator lists and -revokes the orphan credential, then issues a new token. - -Exchange failures are definitive protocol errors: - -- unknown token: `401 invalid-pairing-token`, with no team binding; -- already consumed token: `409 pairing-token-consumed`, with resolved binding; -- expired token: `410 pairing-token-expired`, with resolved binding and - `details.expired_at`. - -The server MUST NOT create a credential on any failed exchange. Concurrent -exchange attempts for one token produce exactly one `200`; all later attempts -produce the consumed response. - -A device revokes its credential with: +Results are ordered by `registered_at` then `team_id`. When more teams share the +name than the server will choose between, it returns +`409 team-name-match-limit-exceeded` with the limit rather than an arbitrary +truncation, so a caller never picks from a silently shortened list. -```http -POST /v1/credentials//revoke -Authorization: Bearer -Agmsg-Protocol-Version: 1 -Agmsg-Team-ID: -``` +### `GET /v1/teams/` + +Returns the same capability snapshot as `GET /v1/capabilities` for the team in +the path, for a client that has resolved an id but has not yet bound its cursors +to it. Scoped by the path id rather than by a header, and `Cache-Control: +no-store`. -The request has no body. A credential may revoke only its own path ID; trying -to revoke another device or an unknown path ID returns `403 -credential-scope-violation` without disclosing whether that ID exists. A -successful response is `Cache-Control: -no-store` and includes the common binding, `credential_id`, `revoked: true`, and -the canonical `revoked_at` timestamp. Self-revocation is idempotent: the same -revoked bearer may retry this route and receive `200`, but MUST receive `401` -from every message, member, and capability endpoint. An administrative control -plane may revoke another device by `(team_id, credential_id)` without possessing -its secret. +### `GET /v1/teams//messages?after=` + +Reads a team's history by path id, with the same cursor, paging, and retention +semantics as `GET /v1/messages` below — including `410 resync-required` when +`after` is under the retention floor. History is paged rather than returned +whole: a team that has been running is thousands of messages, and the caller +already has to handle a moving `min_available_seq` mid-pull. ## `POST /v1/messages` @@ -915,32 +896,32 @@ also supports newer versions. Supported alternatives are advertised only in ## Error and retry semantics All application-generated non-2xx responses use the common error envelope, -except the unauthenticated readiness shape from `GET /v1/health`. +except the readiness shape from `GET /v1/health`. | HTTP | Error code | Meaning | Client action | | --- | --- | --- | --- | | 400 | `invalid-request` | Malformed JSON, header, team ID, cursor, or schema | Do not retry unchanged | -| 401 | `unauthenticated` | Credentials absent or invalid | Stop and refresh credentials | -| 401 | `invalid-pairing-token` | Pairing token is unknown | Stop; request a new token | -| 403 | `forbidden` | Credentials cannot access this team | Stop | -| 403 | `credential-scope-violation` | Credential attempted to revoke another device | Stop | | 403 | `cipher-policy-violation` | Recognized cipher is not currently write-allowed | Refresh capabilities; do not retry unchanged | | 404 | `team-not-found` | Immutable team ID is not provisioned | Stop; never create implicitly | | 409 | `message-uuid-conflict` | UUID payload differs | Stop and surface corruption/conflict | -| 409 | `pairing-token-consumed` | Pairing token was already exchanged | Revoke an orphan if needed; request a new token | | 409 | `read-state-limit-exceeded` | Unabsorbed exact reads exceed a member/team bound | Block that member's writes; retain facts; remediate oldest hole | +| 409 | `team-already-exists` | `POST /v1/connect` named a team id already registered | Stop; nothing was written. Resolve the team instead of registering it | +| 409 | `cipher-profile-mismatch` | Declared team profile disagrees with the envelope offered | Stop; reconcile the declaration before writing | +| 409 | `team-name-match-limit-exceeded` | More teams share a name than the server will choose between | Stop; resolve by `team_id` | | 410 | `resync-required` | Cursor is below retention floor | Enter terminal resync state | -| 410 | `pairing-token-expired` | Pairing token exceeded its 15-minute TTL | Request a new token | | 413 | `request-too-large` | Body exceeds 2 MiB | Split/reduce while preserving exact durable IDs/envelopes | | 422 | `unsupported-cipher` | Envelope version/cipher is not supported by this server | Stop or upgrade/reconfigure | | 426 | `unsupported-protocol-version` | Header absent or differs from path/supported versions | Stop or explicitly reconfigure | -| 429 | `rate-limited` | Temporary admission limit | Retry with backoff and `Retry-After` | | 500 | `internal-error` | Temporary unclassified server failure | Retry exact request with backoff | -| 502 | `unavailable` | Temporary upstream failure | Retry exact request with backoff | -| 503 | `unavailable` | Temporary server/database failure | Retry with backoff | -| 504 | `unavailable` | Temporary upstream timeout | Retry exact request with backoff | | 507 | `sequence-exhausted` | Team sequence reached signed BIGINT maximum | Stop writes; reads remain available | +This table is the complete set the reference server emits. A `502`, `503`, or +`504` reaching a client comes from infrastructure in front of it rather than +from this protocol, carries no guaranteed error envelope, and is handled the +same way as a transport loss below. There is no `401`, `403 forbidden`, or +`429`: the protocol has no per-request credential to reject and the reference +server applies no admission limit. + A POST timeout, transport loss, or `500`, `502`, `503`, or `504` has unknown outcome. The client MUST retry the exact durable ID/envelope batch and rely on complete ack mapping; it MUST NOT mint replacement UUIDs. `409`, `422`, and From a81da2526a7e6f8a8752896da8b7340b07e8f948 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 15:19:06 -0700 Subject: [PATCH 3/6] docs: dissolve ref/, and finish the promotions it was hiding ref/README.md said nobody builds toward anything under it and that no document there may be cited. Eleven documents lived there; nine describe shipped code and were cited 34 times from outside, including by scripts/key.sh, scripts/remote-sync.sh, sqlite-sync.sh and server/spec/v1.md. Making the distinction visible from the path did not stop anyone citing them, so the directory goes rather than the warning getting louder. Promoted nine: the Stage-1 contract, the age-v1 profile, read-state, retention-gap and opaque-envelope specs, ADRs 0005-0007, and the adaptive-catchup design that remote-sync.mjs names in its own comments. Deleted two: device-pairing, which has no implementation and no subcommand, and remote-sync-dogfood, which asked to be deleted once integration/remote reached main (17d0ba7) and taught a connect --token-stdin that no longer parses. Promotion is not a move. Statuses said proposed or dogfood, which was the vocabulary of the directory they sat in; the specs are current and the ADRs accepted, edited first where they still described the pairing and per-device-credential model the server no longer has. Editing them is what the ADR rule allows while they are proposed and unadopted, and leaving that text under docs/adr/ would have shipped a wrong record rather than an unfinished one. The last promotion out of ref/ (75b8b1c, ADR 0003) left five relative links pointing one directory too deep and nothing noticed. All 38 links this move invalidated were rewritten by resolving each basename against the tree, and the checker added in the first commit is what enumerated them. Prose and code paths are a separate instrument: 28 more references live in security.md, security.ja.md and three script headers, where no link checker can see them. --- .../0003-storage-axis-driver-abi-and-scope.md | 10 +- .../{ref => }/0005-remote-sync-contract.md | 47 +- .../0006-composite-read-state-frontier.md | 8 +- .../0007-stable-member-and-roster-identity.md | 27 +- docs/adr/ref/README.md | 35 - .../{ref => }/adaptive-sync-catchup-v1.md | 11 +- docs/design/ref/README.md | 35 - docs/design/ref/device-pairing.md | 811 ------------------ docs/design/ref/remote-sync-dogfood.md | 177 ---- docs/security.ja.md | 24 +- docs/security.md | 24 +- docs/spec/{ref => }/age-v1-profile.md | 8 +- docs/spec/driver-interface.md | 6 +- .../{ref => }/read-state-synchronization.md | 10 +- docs/spec/ref/README.md | 35 - .../retention-gap-resynchronization.md | 10 +- docs/spec/{ref => }/server-opaque-envelope.md | 4 +- docs/spec/{ref => }/stage-1-remote-sync.md | 22 +- scripts/drivers/storage/sqlite-sync.sh | 2 +- scripts/key.sh | 2 +- scripts/remote-sync.sh | 2 +- server/spec/v1.md | 6 +- 22 files changed, 115 insertions(+), 1201 deletions(-) rename docs/adr/{ref => }/0005-remote-sync-contract.md (80%) rename docs/adr/{ref => }/0006-composite-read-state-frontier.md (96%) rename docs/adr/{ref => }/0007-stable-member-and-roster-identity.md (90%) delete mode 100644 docs/adr/ref/README.md rename docs/design/{ref => }/adaptive-sync-catchup-v1.md (97%) delete mode 100644 docs/design/ref/README.md delete mode 100644 docs/design/ref/device-pairing.md delete mode 100644 docs/design/ref/remote-sync-dogfood.md rename docs/spec/{ref => }/age-v1-profile.md (98%) rename docs/spec/{ref => }/read-state-synchronization.md (98%) delete mode 100644 docs/spec/ref/README.md rename docs/spec/{ref => }/retention-gap-resynchronization.md (96%) rename docs/spec/{ref => }/server-opaque-envelope.md (96%) rename docs/spec/{ref => }/stage-1-remote-sync.md (93%) diff --git a/docs/adr/0003-storage-axis-driver-abi-and-scope.md b/docs/adr/0003-storage-axis-driver-abi-and-scope.md index a730035b..feb7f308 100644 --- a/docs/adr/0003-storage-axis-driver-abi-and-scope.md +++ b/docs/adr/0003-storage-axis-driver-abi-and-scope.md @@ -7,14 +7,14 @@ ## Context 1.1.0 shipped the axis-generic driver registry and external-plugin opt-in -([ADR 0002](../0002-driver-discovery-and-plugin-opt-in.md)). 1.1.1 implements the +([ADR 0002](0002-driver-discovery-and-plugin-opt-in.md)). 1.1.1 implements the **storage axis** — the message store, made pluggable — with drivers `sqlite` (default), `jsonl`+`duckdb`, and `redis`. Before writing code, three architectural questions needed locking. An independent design pass (codex, this session) converged with the earlier Fugu-demo design (codex + gemini) and corrected an initial lean toward a subcommand ABI; this ADR records the converged decisions. ADRs are revisable, so it reaffirms or tightens -[ADR 0001](../0001-storage-driver-pluginization.md) where that is the better choice. +[ADR 0001](0001-storage-driver-pluginization.md) where that is the better choice. ## Decision @@ -90,9 +90,9 @@ sqlite). 1.1.2 = `redis` (message store). ## References -- Builds on [ADR 0001](../0001-storage-driver-pluginization.md) and - [ADR 0002](../0002-driver-discovery-and-plugin-opt-in.md). +- Builds on [ADR 0001](0001-storage-driver-pluginization.md) and + [ADR 0002](0002-driver-discovery-and-plugin-opt-in.md). - Spec (where the `storage_*` signatures live, not this ADR): - [`docs/spec/driver-interface.md`](../../spec/driver-interface.md). + [`docs/spec/driver-interface.md`](../spec/driver-interface.md). - Implementation: #51 (epic), #203 (contract), #204 (facade + sqlite), #205 (event-log), #206 (call-site migration), #207 (jsonl+duckdb), #208 (redis). diff --git a/docs/adr/ref/0005-remote-sync-contract.md b/docs/adr/0005-remote-sync-contract.md similarity index 80% rename from docs/adr/ref/0005-remote-sync-contract.md rename to docs/adr/0005-remote-sync-contract.md index dca644f1..67e90c2f 100644 --- a/docs/adr/ref/0005-remote-sync-contract.md +++ b/docs/adr/0005-remote-sync-contract.md @@ -1,6 +1,6 @@ # ADR 0005: Remote synchronization contract -**Status:** proposed (dogfood architecture) +**Status:** accepted **Date:** 2026-07-25 **Deciders:** @fujibee @@ -35,16 +35,13 @@ quarantined, or terminally corrupt. The immutable binding identity is `(server_instance_id, remote_team_id, protocol_version)` plus the storage driver's persistent generation for interpretation of local positions. Endpoint -location and credential rotation do not change stream identity. A different -server instance, remote team, protocol, or local position generation is a -different synchronization namespace. +location does not change stream identity. A different server instance, remote +team, protocol, or local position generation is a different synchronization +namespace. -A device credential is bound to its endpoint origin, server instance, remote -team, and non-secret `credential_id`. It cannot authorize a different binding, -and revocation targets `credential_id`, never the bearer secret as an object -identifier. Secret single-delivery and provisional activation/finalization are -pinned by the onboarding API specification; an acknowledged secret is never -reissued after response loss. +A binding is a local record, not an authorization. It carries no secret, and +every write that changes one advances a revision so a concurrent reconnect +cannot have its newer binding overwritten by a caller that never saw it. ### At-least-once transport has exact conflict semantics @@ -78,8 +75,9 @@ per-agent wake decisions stay local. The synchronization server never chooses team keys and never receives plaintext private team or recovery keys. Sealing and opening happen client-side. This is a content-confidentiality boundary, not an anonymity claim: team identity, wire -ID, sequence, server receipt time, envelope version, cipher, key epoch, digest, -size, timing, and traffic frequency remain visible as defined by the protocol. +ID, sequence, server receipt time, envelope version, cipher, the scheme-defined +key identifier (`key_id`), digest, size, timing, and traffic frequency remain +visible as defined by the protocol. ### Progress layers remain independent @@ -104,10 +102,11 @@ again. ### Onboarding cannot overstate history durability -A successful credential connection is not proof that local history is durable -on the server. When onboarding backfills an existing store, retention cannot -drop a range required by the promoted snapshot until the server has the -manifest's terminal durable acknowledgement. +A successful connect is not proof that local history is durable on the server. +Registering a team and replicating its history are separate operations with +separate crash boundaries, and only the acknowledgements of the second make a +range durable. A client MUST NOT treat connect as backfill, and MUST NOT retire +local state on the strength of it. Proofs over local source identities and proofs over translated wire identities use separate domains. They are never compared or reused as if translation @@ -140,8 +139,8 @@ preserved identity bytes. loss requiring explicit acknowledgement, not a successful pull. - **Plaintext-only projections or a parallel table.** Rejected because E2EE would become a feature downgrade and two implementations would diverge. -- **Implicit full-history durability at connect.** Rejected because credential - activation and history backfill have different crash boundaries. +- **Implicit full-history durability at connect.** Rejected because team + registration and history backfill have different crash boundaries. ## Consequences @@ -156,9 +155,9 @@ preserved identity bytes. ## Normative specifications -- [HTTP API v1](../../../server/spec/v1.md) -- [Stage-1 local-first remote synchronization](../../spec/ref/stage-1-remote-sync.md) -- [Cipher-independent opaque-envelope server schema](../../spec/ref/server-opaque-envelope.md) -- [Retention-gap resynchronization](../../spec/ref/retention-gap-resynchronization.md) -- [Storage driver interface](../../spec/driver-interface.md) -- [age-v1 profile](../../spec/ref/age-v1-profile.md) +- [HTTP API v1](../../server/spec/v1.md) +- [Stage-1 local-first remote synchronization](../spec/stage-1-remote-sync.md) +- [Cipher-independent opaque-envelope server schema](../spec/server-opaque-envelope.md) +- [Retention-gap resynchronization](../spec/retention-gap-resynchronization.md) +- [Storage driver interface](../spec/driver-interface.md) +- [age-v1 profile](../spec/age-v1-profile.md) diff --git a/docs/adr/ref/0006-composite-read-state-frontier.md b/docs/adr/0006-composite-read-state-frontier.md similarity index 96% rename from docs/adr/ref/0006-composite-read-state-frontier.md rename to docs/adr/0006-composite-read-state-frontier.md index aaa71a64..1765d02d 100644 --- a/docs/adr/ref/0006-composite-read-state-frontier.md +++ b/docs/adr/0006-composite-read-state-frontier.md @@ -1,6 +1,6 @@ # ADR 0006: Composite read-state frontier -**Status:** proposed (dogfood architecture) +**Status:** accepted **Date:** 2026-07-25 **Deciders:** @fujibee @@ -128,7 +128,7 @@ fail-closed overflow are architecture requirements. ## Normative specifications -- [Stage-2 read-state synchronization](../../spec/ref/read-state-synchronization.md) -- [HTTP API v1](../../../server/spec/v1.md) -- [Storage driver interface](../../spec/driver-interface.md) +- [Stage-2 read-state synchronization](../spec/read-state-synchronization.md) +- [HTTP API v1](../../server/spec/v1.md) +- [Storage driver interface](../spec/driver-interface.md) - [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md) diff --git a/docs/adr/ref/0007-stable-member-and-roster-identity.md b/docs/adr/0007-stable-member-and-roster-identity.md similarity index 90% rename from docs/adr/ref/0007-stable-member-and-roster-identity.md rename to docs/adr/0007-stable-member-and-roster-identity.md index 8c860b2d..84f7b302 100644 --- a/docs/adr/ref/0007-stable-member-and-roster-identity.md +++ b/docs/adr/0007-stable-member-and-roster-identity.md @@ -1,6 +1,6 @@ # ADR 0007: Stable member and roster identity -**Status:** proposed (design architecture) +**Status:** accepted **Date:** 2026-07-25 **Deciders:** @fujibee @@ -22,10 +22,10 @@ design/specification. ### Distinguish every identity layer -- A **team owner** is the one human account authorized to control the hosted - team. V1 has exactly one immutable owner. Pairing another device must prove - the same owner; it is not an invitation for another owner. Server, CLI, and - credential issuance all enforce this boundary. +- A **team owner** is the one human account that controls the hosted team. V1 + has exactly one immutable owner. Bringing a second machine onto a team is not + an invitation for another owner; it is the same owner reaching the same + server, which is what the sync protocol treats as the permission. - A **local team identity** is a stable opaque UUID created locally. It is not the team display name and is not the remote binding ID. - A **member** is an agent principal anchored by stable opaque `member_id`. @@ -105,9 +105,10 @@ The eventual wire spelling belongs in the versioned protocol specification. ### Lifecycle operations do not collapse domains Leaving or deleting the last local agent placement does not delete the portable -member catalog, local team identity, remote binding, or team key. Rename and -retirement do not revoke device credentials. Credential revocation does not -rewrite or cryptographically erase a member's historical messages. +member catalog, local team identity, remote binding, or team key. Removing a +member from the roster does not rewrite or cryptographically erase that +member's historical messages, and does not revoke a key already distributed — +anyone holding an epoch's identity can still read what was sealed to it. Two independently populated teams are never merged implicitly. A reconnect may reattach only with exact prior identity and binding proof. Any future populated @@ -152,8 +153,8 @@ reconciliation protocol; matching display names are insufficient. concurrent mutations and makes acknowledgement retry non-convergent. - **Implicit populated-to-populated merge.** Rejected until an explicit identity/history reconciliation protocol exists. -- **Treat removal as credential revocation or erasure.** Rejected because - membership, device authorization, and ciphertext history are separate facts. +- **Treat removal as key revocation or erasure.** Rejected because membership, + key distribution, and ciphertext history are separate facts. ## Consequences @@ -171,8 +172,8 @@ reconciliation protocol; matching display names are insufficient. ## Normative design and specifications -- [Remote sync design](../../design/remote-sync.md) -- [HTTP API v1](../../../server/spec/v1.md) -- [Stage-2 read-state synchronization](../../spec/ref/read-state-synchronization.md) +- [Remote sync design](../design/remote-sync.md) +- [HTTP API v1](../../server/spec/v1.md) +- [Stage-2 read-state synchronization](../spec/read-state-synchronization.md) - [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md) - [ADR 0006: Composite read-state frontier](0006-composite-read-state-frontier.md) diff --git a/docs/adr/ref/README.md b/docs/adr/ref/README.md deleted file mode 100644 index 60615cfe..00000000 --- a/docs/adr/ref/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Reference - -**Nobody is building toward anything in a `ref/` directory.** These are kept -for what they worked out, not as a description of the product or a plan for it. - -Some were reviewed, some reviewed many times. A passed review says the -reasoning holds together — not that the design was adopted. Some say -"implemented", describing code that exists rather than a commitment to keep it. -Read every normative-sounding sentence here as *"if this had been adopted, it -would have worked like this."* - -Do not implement from a document under `ref/`. Do not cite one as the reason -something is the way it is. Do not build a runbook or a README step from one. - -Work that is actually being built toward lives in the parent directory, whether -or not any code exists yet. - -## Why this exists - -A document here recorded that the shipped onboarding creates a team in the -opposite order from the intended one, and named the schema constraints causing -it. Work continued against the shipped order for two days — nobody treated the -document as a gate, because a status line inside a file is connected to -nothing, and the files beside it described working code. A runbook was then -written teaching a command that the same directory said should not exist. - -Splitting by directory makes the distinction visible from the path, before the -file is opened. - -## Using these - -They remain worth reading. Constraints, failure modes, and wire shapes worked -out here often survive a change of direction even when the design around them -does not — that reasoning is why they were kept rather than deleted. Take the -argument; do not take the conclusion as current. diff --git a/docs/design/ref/adaptive-sync-catchup-v1.md b/docs/design/adaptive-sync-catchup-v1.md similarity index 97% rename from docs/design/ref/adaptive-sync-catchup-v1.md rename to docs/design/adaptive-sync-catchup-v1.md index c027dc79..e9aaa3ef 100644 --- a/docs/design/ref/adaptive-sync-catchup-v1.md +++ b/docs/design/adaptive-sync-catchup-v1.md @@ -1,9 +1,10 @@ -# Adaptive sync catch-up (design draft) +# Adaptive sync catch-up -Status: DRAFT for review. OSS-side; destination `integration/remote`. -No wire-contract or schema change → no ADR (see adr-discipline); a design doc -suffices. If the design later needs to touch `server/spec/v1.md`, that part -gets its own ADR judgement. +Status: current — implemented in `scripts/internal/remote-sync.mjs`, which +names this document at the two-stage page policy and the saturation signal. +No wire-contract or schema change, so no ADR (see adr-discipline); a design doc +suffices. If a later change needs to touch `server/spec/v1.md`, that part gets +its own ADR judgement. ## Problem diff --git a/docs/design/ref/README.md b/docs/design/ref/README.md deleted file mode 100644 index 60615cfe..00000000 --- a/docs/design/ref/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Reference - -**Nobody is building toward anything in a `ref/` directory.** These are kept -for what they worked out, not as a description of the product or a plan for it. - -Some were reviewed, some reviewed many times. A passed review says the -reasoning holds together — not that the design was adopted. Some say -"implemented", describing code that exists rather than a commitment to keep it. -Read every normative-sounding sentence here as *"if this had been adopted, it -would have worked like this."* - -Do not implement from a document under `ref/`. Do not cite one as the reason -something is the way it is. Do not build a runbook or a README step from one. - -Work that is actually being built toward lives in the parent directory, whether -or not any code exists yet. - -## Why this exists - -A document here recorded that the shipped onboarding creates a team in the -opposite order from the intended one, and named the schema constraints causing -it. Work continued against the shipped order for two days — nobody treated the -document as a gate, because a status line inside a file is connected to -nothing, and the files beside it described working code. A runbook was then -written teaching a command that the same directory said should not exist. - -Splitting by directory makes the distinction visible from the path, before the -file is opened. - -## Using these - -They remain worth reading. Constraints, failure modes, and wire shapes worked -out here often survive a change of direction even when the design around them -does not — that reasoning is why they were kept rather than deleted. Take the -argument; do not take the conclusion as current. diff --git a/docs/design/ref/device-pairing.md b/docs/design/ref/device-pairing.md deleted file mode 100644 index b19a441a..00000000 --- a/docs/design/ref/device-pairing.md +++ /dev/null @@ -1,811 +0,0 @@ -# Device pairing (`key request` / `key approve`) - -> **SUPERSEDED.** The onboarding this describes was replaced by -> [`docs/design/remote-sync.md`](../remote-sync.md), which states the replacement -> from its own side. Kept as design history: the reasoning here is why the -> current shape is what it is, and the findings it records were closed rather -> than dropped. Do not build to it. - -**Status: draft, revision 7.** Replaces the key-pairing work that the remote -connect onboarding design had held back as NOT READY. One-directional -authentication was closed by the onboarding pivot's post-decryption -bidirectional SAS. Revision 1 was returned CHANGES REQUIRED with eight blockers -(B1-B8), alongside eight further gaps from the mobile client. Revision 2 -rebuilt the document around observability and closed B1 and the frame, -freeze-order, rate-limit-identity, non-addressee-validation, rollback-baseline -and classification findings; it was returned with six more (R1-R6), all of -them exact state or wire semantics, which revision 3 addressed. Revision 3 -was returned with two blockers. **T1**: the state table named states but never -said what each one *leaves via*, and neither the TTL nor abort was scoped to -the states it may act on. **T2**: the `completed` acknowledgement had no -durable owner, so a device that crashed between promoting the key and -acknowledging it left device 1 waiting on a state that would never arrive. -Revision 4 closed both on the server, and was returned with two more (U1, U2) -showing that each had been left open on the *client*. **U1**: scoping the TTL -server-side did not stop the local deadline handler from erasing staged -material while a consume was in flight, which re-opened the exact race T1 -existed to close. **U2**: `activation_failed` was made mandatory for a failure -mode — reinstall, keystore loss — that destroys the credential and journal -needed to send it, so the transition was unexecutable in the case that -motivated it. Revision 5 closed both, adding one new state, and was returned -with three more (V1-V3) — all in the machinery revision 5 had just introduced. -**V1**: the staged-material retention bound erased on an unknown outcome and -then assumed a transition that may not be legal, while inviting the implementer -to erase the very journal needed to report anything. **V2**: making `abandoned` -terminal discarded a late but genuine completion from a device that came -back — throwing away an authenticated fact the design had just finished -arguing must never be thrown away. **V3**: the completion deadline gating -`abandoned` was never connected to the state or status contract, so it had no -origin, no binding, and nothing stopping a server from moving it. Revision 6 -closed those three and was returned with three more (W1-W3), all in the -late-evidence machinery it had just added. **W1**: revision 5's definition of -`abandoned` was still present further down, contradicting revision 6's own -replacement of it in the same document. **W2**: appending evidence was -indistinguishable from a server mutating a terminal request under the status -contract's version rule. **W3**: the late acknowledgement was described as -append-only without being tied to the operation ledger, so every lost response -could add another row. - -Pairing is the **primary path for a second device**; the recovery key is the -disaster path for when no live machine remains. Pairing is OSS because it is -closed within one team. - -## What revision 1 got structurally wrong - -Every reviewer landed on the same defect from a different side. Revision 1 -described the **authoritative state on the server** and never described **how -each device observes that state**. Consequently: - -- `aborted` existed as a state with no way for the other device to learn it, - so a device-1 refusal showed up on device 2 fifteen minutes later as - "expired" — a false statement to the human, not merely a missing string. -- `approved` was observable only by the arrival of the payload itself, so - "approved, delivery in flight" and "not approved yet" were indistinguishable - — and they call for opposite human actions. -- Decryption failure on a request addressed to *this* device was unnamed, so - the most likely outcome of an actual substitution attack had no state. -- The durability ordering between the server's `consumed` and the device's - local key commit was undefined, so a crash could leave the two disagreeing - in either direction. - -So this revision is organised around **observability first**: every -transition states who owns the authority, how each side learns of it, how -that observation is authenticated and kept fresh, how it is re-obtained after -a loss, and what happens when it cannot be obtained at all. States that no -one can observe are not states. - -## Classification - -The decision **"pairing key delivery never uses the team-message cipher -profile, and never rides ordinary message admission"** is hard to reverse and -belongs in the consolidated remote synchronization contract ADR as a negative -decision, not in a new ADR of its own. The literal `pair-v1` identifier, its -frame, the state machine, and the TTL belong in a **versioned spec under -`docs/spec/`** once this design settles. This document is the pre- -implementation study, and is expected to be superseded by that spec rather -than to survive as the normative text. - -## E1 — pairing cannot use `age-v1`, and cannot constrain it either - -`age-v1` forbids this payload three times over: `cipher` is fixed and admits -no content negotiation; `key_id` names one immutable recipient-set epoch; and -a writer must encrypt to every recipient of the selected manifest and to no -real recipient outside it, with the reader required to find its own recipient -in that manifest. A pairing blob is encrypted to a one-time recipient that -must never enter a manifest. The profile's own rule — an incompatible change -requires a new identifier — gives the answer: a separate `pair-v1` profile, -with `age-v1` untouched and no `age-v2`. - -**Revision 1 then contradicted itself** by reserving the `pair-` prefix in the -`key_id` grammar. `age-v1`'s `key_id` is `[a-z0-9][a-z0-9._-]{0,63}` and does -not exclude `pair-*`, so an existing legitimate epoch may already use it. -Reserving it retroactively *is* a change to `age-v1`, which the same paragraph -promised not to make, and would additionally require a state scan and a -versioned contract change. - -**Dispatch is therefore on the outer `cipher` first, then `key_id` within that -profile.** `(cipher, key_id)` is the key; `key_id` shape rules apply only -inside `pair-v1`. No prefix is reserved in any other profile's label space. - -## Delivery admission - -A `pair-v1` envelope **must not be accepted through ordinary message POST.** -Allowing it there would let any ordinary writer bypass the team's effective -cipher policy and inject arbitrary blobs, while leaving the existing -validators unchanged would reject it outright — the draft assumed both at -once. - -Delivery is a **dedicated authenticated pairing-delivery operation** that -verifies team, request, approver credential, request state and version, and -the frozen recipient digest, admits exactly one delivery per request, and -performs the request CAS and the control-row append **in one transaction**. - -The abort notice, the `completed` acknowledgement, the `activation_failed` -report and the `abandoned` declaration are separate operations of the **same -dedicated, authenticated family**: -each verifies the credential, re-checks the request state and version inside -the lock, and performs its CAS together with any control-row append in one -transaction. None of them is reachable through ordinary message POST either. - -Its effects on every stream-level quantity are pinned here rather than -inherited. "Must not leave a hole" was not a specification — it left each -implementer to invent a different frontier. - -- The control append **consumes exactly one `server_seq`** and **appears in - the pull transport cursor**, like any other row. -- It is **excluded from** the ordinary message count, the unread count, agent - projection, and the user read manifest. -- The **read frontier may cross the control seq as automatically processed**, - and must not fabricate a user-read fact for it. -- **Retention and resync treat it identically to an ordinary row**, keeping - the same identity across both. -- The **approver's own echo is exact replay** and is not re-emitted. - -Which record kind of the existing Stage 1/2 ABI carries this is a contract -point to settle with the storage/server owner before implementation; it is -not a free choice for this document to make. - -## The `pair-v1` envelope and frame - -```json -{ "v": 1, "cipher": "pair-v1", "key_id": "", "blob": "" } -``` - -The blob is produced by age's native X25519 recipient encryption — the -"zero new cryptography" constraint holds; what is new is the envelope and the -frame, not the primitive. - -**The frame is `pair-v1`'s own, not `age-v1`'s.** Revision 1 said the two -share their base64/age-file encoding and implied the rest carried over; -`age-v1`'s validator also binds `wire_id`, team, cipher, `key_id` and the -message JCS, none of which apply here. The correct split is a **shared outer -age parser** plus a **profile-specific frame and policy validator**. - -`pair-v1`'s authenticated bytes bind, uniquely and reconstructibly from the -outer values, with constant-time comparison: - -protocol version; `server_instance_id`; `team_id`; the **client-generated -control `wire_id`**; `request_id`; the outer `cipher` and `key_id`; the -**frozen recipient public key**; the **approver nonce**; the delivered -epoch's `key_id`, `epoch_revision` and snapshot digest; and the delivered -bundle digest. - -**`server_seq` is deliberately not bound.** Sealing happens once, before the -envelope is offered to the server, while `server_seq` is assigned in the -append transaction — a sealed ciphertext cannot bind a number that does not -exist yet. Binding the client-generated `wire_id` instead is the same shape -`age-v1` already uses. Pre-reserving a sequence number to make it bindable -would introduce another state and another crash contract for no gain. - -**Validation reuses `age-v1`'s hardening rather than relaxing it:** exactly -one real X25519 stanza (GREASE excluded from that count), the same strict -Rust GREASE subset already pinned for `age-v1`, the same total/header/line -bounds, and `scrypt`, SSH, `plugin-` and every other active stanza type -rejected before the decryptor is invoked. - -## A1 — the confirmation code, and the ordering that makes a short SAS sound - -The original code was derived from the temporary public key's fingerprint, -which is precisely what a substituting server can grind offline. The redesign -separates the two jobs that construction conflated. - -**The request code is not an authenticator.** It is a selector plus the -human's authorization gesture, generated as a **random value by the requesting -device** and derived from no key material, so nothing about the keys can be -ground to collide with it. Its grammar is strict and fixed, collisions are -retried at generation, and it is never written to logs or telemetry — it is -not a bearer secret (which is why passing it as `approve`'s argument does not -violate the argv prohibition), but there is no reason to retain it either. - -**Authentication lives in the SAS**, derived from the frame's authenticated -bytes and displayed by both devices for the human to compare. It binds **the -recipient public key each side actually used**, which is what makes -substitution visible: a server that swapped in its own recipient sees one -value, the requester derives another, and re-sealing to the real requester -does not repair the difference. - -**The approver nonce removes the offline grind only if the ordering is -normative**, which revision 1 asserted rather than required. A server CAS is -not proof that the recipient was fixed before the nonce existed. The approving -device therefore MUST: - -1. resolve the code, obtaining the recipient bytes together with the request - id and version, and **freeze them locally**; -2. only then generate the nonce; -3. never re-fetch the recipient, sealing to the frozen snapshot; -4. not reveal the nonce to the server before the sealed request. - -The CAS re-checks the frozen recipient digest and the request version. -**Sealing happens once**: a retry re-sends the exact bytes from a durable -outbox and never regenerates the nonce, the key, or the envelope, because -regeneration would produce two different SASes for one request whenever a -response is lost. - -An outbox is only half of that, though — it also needs matching replay -semantics on the server, or a lost response turns into a permanent failure. - -Before anything touches the network, the approver durably records -`(operation_id, request id and version, frozen recipient digest, control -wire_id, exact envelope digest and bytes)` atomically. The server performs -`pending → approved`, the control append, and the storage of its result **in -one transaction**. A retry carrying the same `operation_id` and request -digest returns the **stored acknowledgement**, including after the request is -already `approved`; if any of those fields differs it fails closed with a -conflict rather than producing a second delivery. Without this, the ordinary -response-loss retry would observe `approved` and report failure for a -delivery that in fact succeeded. - -**A second local invocation resolving the same code resumes the existing -outbox entry, or is refused single-flight** — it never starts a fresh -resolve. Only the approver holding the winning delivery acknowledgement -displays a SAS. This is what actually closes the "resolve twice and swap the -frozen snapshot" hole; freezing alone did not. - -Length, alphabet, and **grouping** of both strings are fixed as numbers in -the versioned spec, together with the attempt rates they depend on (see -"Before implementation"). Grouping is protocol-level, not per-client: humans -compare two strings far more accurately when the separation is identical on -both screens, so a CLI and a mobile client must not choose it independently. -The code and the SAS must also be visually distinguishable from each other. - -## Rate limiting - -A pending cap alone is not an online-guessing bound — an attacker can spend a -whole TTL guessing against the same pending set. Approval requires an -**authenticated approver credential**, with bounded attempt rates per account, -team, credential and address, and a global circuit breaker. `expired` and -`not-found` are distinguished **only after authentication**, and the response -carries no other metadata. - -The per-machine cap counts against a **stable installation identity the server -derives from the credential**, never a self-reported name, address or -temporary key, and is ANDed with the per-team and per-account caps because -credentials churn. Reaching a cap refuses new requests rather than evicting -old ones, so flooding cannot flush a legitimate pending request. - -## State, authority, and observation - -| state | meaning | authority | leaves via | -|---|---|---|---| -| `pending` | registered, awaiting approval | server | `approved`, `aborted`, `expired` | -| `approved` | approval reserved, delivery admitted | server | `consumed`, `aborted`, `expired` | -| `consumed` | SAS match accepted; local activation authorized | server | `completed`, `activation_failed`, `abandoned` | -| `completed` | the requester promoted the key and acknowledged it | server, reported by device 2 | terminal | -| `activation_failed` | the requester still exists and reports that it did **not** promote the key | server, reported by device 2 | terminal | -| `abandoned` | the approver stopped waiting, and **activation was unknown at that moment**; late evidence may still be appended | server, declared by device 1 | terminal | -| `expired` | the TTL elapsed **while still `pending` or `approved`**, and expiry won | server | terminal | -| `aborted` | cancelled by an authorized party | server | terminal | - -`pending → approved` is the **single-use reservation of the approval**; -`approved → consumed` **authorizes local activation**. Revision 1 called the -whole thing a single terminal transition and said the request was consumed at -approval, which contradicted its own table. - -**Every transition not in the table is prohibited, including every -self-transition.** A retried operation returns the stored acknowledgement for -its `operation_id` (see A1) rather than re-entering a state it already -occupies; a request never moves backwards, and nothing leaves a terminal -state. Revision 3 listed the states without their exits, which left the two -questions below answerable in either direction. - -**The TTL gates progress out of `pending` and `approved` only.** Unscoped, it -reads as a deadline on the whole request — and that reading is wrong in the -one case where it matters most. Once the `consumed` CAS has won before the -deadline, the human has already compared the SAS and device 2 is authorized to -activate. The local promote and the `completed` acknowledgement **must remain -resumable after the deadline passes**; otherwise a legitimate last-minute -confirmation is destroyed by a few milliseconds of clock skew or one slow -write, and the human is shown an attack-shaped failure produced by a timer. -Expiry may therefore be applied only to a request still in `pending` or -`approved`, and the deadline re-check is a **precondition of the `consumed` -CAS**, not of the steps after it. - -**Scoping the TTL on the server does not scope it on the client**, and -revision 4 stopped at the server. The local deadline handler still erased the -staged plaintext and the one-time identity the moment the wall clock crossed -the deadline, which re-opens the same race one layer down: device 2 sends the -consume just before the deadline, the server wins the CAS inside it, the -acknowledgement is lost or the local clock crosses first, and the client — -still believing the request is `approved` — destroys the material the server -has already authorized it to promote. A server-supplied absolute deadline fixes -disagreement about *when* the deadline is; it does nothing about a response -that never arrived. So the rules below are normative on the client: - -1. **The consume is durable and single-flight.** Before it touches the network, - device 2 records `(operation_id, request_id, request version)` in the same - durable outbox that carries the completion. A second attempt resumes that - entry; it never starts a parallel consume. -2. **The deadline may stop a consume from starting. It may never resolve one - that has started.** While an outbox entry is unreconciled, the request is not - expired locally, no matter what the clock says. -3. **Reconcile before erasing.** On restart, or when the deadline passes with an - unreconciled entry, device 2 re-fetches the stored result for that - `operation_id`. If it is `consumed` or later, device 2 **proceeds to the - promote even though the deadline has passed** — that is precisely the - guarantee this section exists to give. Only an authenticated answer that the - request is `expired`, `aborted`, or was never consumed permits erasure. -4. **Unreachable is not an answer.** If the server cannot be reached, the - material stays staged and the client keeps retrying. Erasure on a timer - while the outcome is unknown is the defect, not the safeguard. - -Staged plaintext is still not kept forever: it has its own **retention bound**, -which is deliberately a different and much longer value than the pairing TTL -and is not synchronised with it. A bound that could elapse alongside the -deadline would recreate the race; one that cannot only costs the human a -re-request. - -**That bound is the one explicit exception to rule 4, and it is written as an -exception rather than smuggled in as a detail.** Erasing while the outcome is -unknown trades recoverability for the guarantee that decrypted key material -does not sit on a device indefinitely. Revision 5 made the trade but got its -consequences wrong twice. It said the client "reports `activation_failed` at -the next opportunity" — but that is a CAS from `consumed`, and a client whose -outcome is unknown does not know the server is at `consumed`; the approval may -never have happened at all. And by erasing "the material" it invited the -implementer to take the request journal with it, which is U2 reproduced by -hand. - -The exception is therefore scoped on both sides. - -**Only the staged plaintext and the one-time secret are erased.** The request -id and version, the `operation_id`, the reference to the credential, and the -exact envelope digest survive until reconciliation is complete. They are not -key material; they are the evidence needed to close the request honestly, and -discarding them is exactly what makes a request unreportable. - -**What happens on the next successful contact is a branch on the server's -state, never an assumption about it:** - -| stored result / status | what device 2 does | -|---|---| -| `consumed` | report `activation_failed` — the CAS is legal and the statement is true, since no promote was ever committed | -| `expired` or `aborted` | accept that terminal state; there is nothing to report | -| `pending` or `approved` | the consume never landed. Do **not** start a new one, because the material is gone. This branch only exists when the retention bound elapsed early relative to the TTL (a stalled worker, a suspended device); **device 2 aborts the request with its own requester credential**, which it still holds in this case, rather than leaving a live request the human can still approve into nothing | -| `completed` | a local invariant violation: with no completion outbox entry this device cannot have promoted. Fail closed and surface it rather than reconciling it away | -| `abandoned` | the approver closed it while this device was dark; accept the terminal state and re-request | - -**Abort is likewise limited to `pending` and `approved`.** After `consumed` -there is nothing left to cancel: device 2 already holds decrypted material and -the authorization to promote it, so an abort arriving then would either be -ignored — making it a lie to whoever issued it — or would have to reach into -another device's local key state, which no server-side transition can do. An -abort attempted against `consumed` or any terminal state is refused with the -current state rather than silently accepted, so the human learns that the -window for cancelling has closed. The remedy after `consumed` is `abandoned` -below — a different statement, because by then a key may actually exist. - -**`consumed` is not "committed".** Revision 2 said it was, while the -durability order puts the local promote *after* the server CAS — so a crash -in between leaves the server reporting a completed delivery while device 2 -holds no active key. That is the same class of lie this revision exists to -remove. `consumed` therefore means only that the human accepted the SAS and -local activation is authorized, and `completed` is a separate, -idempotently-acknowledged state that device 2 reports **after** the promote. -Until that acknowledgement arrives, device 1 says "confirmed; the other -device is finishing" rather than claiming completion. - -**A requester that loses the staged material before promoting it must -terminalise the old request, not merely abandon it.** Revision 3 said recovery -is a new request, which is true for device 2 and useless for device 1: nothing -in the protocol would ever have moved the old request off `consumed`, so -device 1 would display "the other device is finishing" until the human gave -up — and the TTL cannot rescue it, precisely because expiry no longer applies -past `consumed`. Device 2 therefore reports **`activation_failed`**: an -authenticated, idempotent terminal transition carrying the request id and -version, admitted through the same dedicated path as the `completed` -acknowledgement. - -`activation_failed` is refused once the key is promoted. The check is **local -first** — device 2 must not send it when its own completion outbox records a -committed promote — and is enforced again by the server as a **CAS from -`consumed`**, so a retry that crosses a successful `completed` cannot -un-complete a delivery. The two are mutually exclusive outcomes of the same -transition and whichever is recorded first wins; the loser receives the -recorded state rather than an error, because after a crash device 2 genuinely -cannot know which of its attempts was received. - -**But `activation_failed` cannot be required of a device that no longer -exists.** Revision 4 made it mandatory for "the one-time identity or the staged -material is gone (reinstall, keystore loss)" — and a reinstall destroys the -request journal and the credential the report itself needs. There would be no -authenticated party left to send it. The two losses must be separated, because -only one of them leaves a reporter behind: - -- **The staged bundle is lost, the durable request journal and the credential - survive** (a wiped cache directory, a failed atomic promote). Device 2 can - still authenticate and still knows the request id and version, so it reports - `activation_failed` and the human re-requests. This is the case revision 4 - described. -- **The installation is lost** (reinstall, keystore loss, a destroyed or - discarded device). Nothing on device 2 can be reconstructed — not the - credential, not the request id, in general not even the fact that the request - had reached `consumed`. **No report is possible from that side at all**, and - no amount of local journalling fixes it, because the journal dies with the - installation. - -The second case needs a different authority, and the only party that is both -stuck and still authenticated is **device 1**. It may therefore declare -**`abandoned`**: an authenticated terminal transition from `consumed` under the -same owner, meaning **"the approver stopped waiting at time T, and activation -was unknown at that time."** The wording matters and is fixed here rather than -left to the reader: this state must not be defined as "no completion is coming", -because a device that returns after the deadline disproves exactly that (see the -late-evidence rule below). A statement about what was known at T stays true no -matter what arrives afterwards. - -**`abandoned` is deliberately not `activation_failed`, and deliberately not -`aborted`.** `activation_failed` is a positive statement by the authoritative -party that no key was promoted — strictly stronger information, and it implies -no further action. `aborted` says the delivery never happened at all. Neither -is true here: after `consumed`, device 2 may in fact hold a live key, and the -approver cannot distinguish "the device was wiped" from "the device promoted -the key and can no longer reach the server" or from "the device is in someone -else's hands." Collapsing the three would either force needless key rotation -after an ordinary failed activation, or — far worse — let a genuinely -unknown activation read as "not activated". So `abandoned` terminalises the -request *and* tells the human the epoch must be treated as possibly delivered: -if the device is not recoverable and trusted, rotate. - -`abandoned` is permitted only from `consumed`, only after a **server-supplied -completion deadline** has elapsed, and it loses to a `completed` or -`activation_failed` that is recorded first — the same CAS from `consumed` that -those two contend on. The completion deadline is a separate value from the -pairing TTL and must be: the TTL governs the human's approval window and no -longer applies once `consumed` is reached, so it cannot also bound the -activation that follows it. It exists to keep the approver from abandoning a -device that is merely slow, and it is what device 1 is counting down while it -displays "the other device is finishing." - -**That deadline is created by the `consumed` CAS itself.** It cannot be pinned -at registration next to `expires_at`, because at registration the time of the -consume is not yet known. The server records -`completion_deadline = + ` **once, in the same transaction as the `consumed` CAS**, returns it in -the status response alongside the request version, and never extends or -recomputes it. The `abandoned` CAS re-checks it against the server clock; -device 1's countdown is display, not authority. Revision 5 introduced this -deadline as a gate without saying where it came from — which left it -unimplementable, and left a server free to move it. - -**A terminal `abandoned` must not discard a late completion.** The state -explicitly covers "device 2 promoted the key but cannot reach the server", so -that device can return after the deadline, and its completion outbox will -faithfully re-send the acknowledgement it still owes. If the CAS simply -refuses, the protocol has been handed a new authenticated fact — a key really -was activated — and thrown it away, leaving the request reading "activation -unknown" forever. That is this document's own defect one more time: an -observation exists and the design declines to record it. - -Overwriting `abandoned` with `completed` is wrong in the other direction, and -worse. The human already acted on "unknown", possibly by rotating the epoch, -and rewriting the state to a clean success destroys the record of why. - -So the state stays terminal and **the late acknowledgement is recorded beside -it, append-only, as late-completion evidence**: authenticated, bound to the -request id and to the state version the request was frozen at, carrying when it -arrived. Status reports it and audit retains it. - -**Evidence gets its own counter, because the request version cannot serve -both.** The status contract says a client rejects a mutation carried at an -unchanged version — so an append that leaves the version alone is -indistinguishable from a server lying about a terminal request, while advancing -the request version would leave a terminal state with a moving concurrency -token and no defined precondition for the acks that bind it. Neither is -acceptable, so they are separated: - -- The **request version counts state transitions only**, and is **frozen when - the request becomes terminal**. Every CAS precondition keeps referring to it. -- **`evidence_revision`** starts at zero and increments **once per accepted - evidence append**, in the same transaction as the append. -- Status returns **both**, and the client's freshness rules apply per counter: a - lower value of either is a regression and is rejected; a **state** change at - an unchanged request version is rejected; an **evidence** change at an - unchanged `evidence_revision` is rejected. -- The late acknowledgement binds the **frozen request version**, not - `evidence_revision` — the returning device cannot know how many evidence - rows it is arriving behind, and requiring it to would make a correct retry - fail. - -**The late acknowledgement is the same operation as the ordinary one, so it -obeys the same ledger.** It arrives from the same durable outbox, carrying the -same `operation_id`, and "append-only" on its own would let every lost response -add another row. It is therefore pinned to the operation ledger A1 already -establishes: - -- The **same `operation_id` with a byte-identical canonical request returns the - stored acknowledgement** and results in **exactly one** evidence row, however - many times it is retried. -- The **same `operation_id` with any field differing** — request, version, - evidence payload, anything — **fails closed with a conflict** rather than - recording a second version of events. -- An acknowledgement that won **before** the request became `abandoned` is - stored as `completed`; one recorded **after** is stored as evidence. A - response-loss retry of either converges on **its own** stored result. The - outcome is decided once, by whichever reached the server first, and no retry - can move a request across that line afterwards. -- The state transition, the evidence append, **the applicable counter update - (`evidence_revision` for a late append, the request version for a state - transition)**, and the stored-result write happen in **one transaction**. A - crash cannot leave evidence without a result, or a result without the row it - describes. - -Late evidence is not a reason to un-rotate anything; its value is the opposite. -It tells the human that an epoch they treated as possibly-delivered was in fact -delivered, and to which device, which is what makes the earlier rotation -decision reviewable rather than unfalsifiable. - -**Any live installation under the owner holding an approver credential may -declare `abandoned`, not only the installation that approved.** Requiring the -original approver would rebuild the failure this state exists to solve, since -that installation can be precisely the one that is gone. The declaring -installation's identity is recorded with the transition, so "who closed this, -and when" is an audit fact rather than an inference. - -**A new request never inherits an old one's outcome.** Re-requesting while the -previous request is still unresolved is the normal case here, not the edge one: -the `abandoned` path cannot resolve until the completion deadline, and the -human will reasonably start again before then. The two therefore coexist as -distinct `request_id`s with distinct status entries, and the client must show -them as separate attempts. A later request reaching `completed` must not close, -recolour, or hide the earlier one — the earlier request terminalises only -through its own observed transition. Otherwise a successful second pairing -would silently present the first as finished, which is the same "unobserved -outcome rendered as success" this design has been removing throughout. - -Late-completion evidence is scoped the same way: it belongs to the -`request_id` that produced it and is never absorbed into a later request's -success. The two attempts can each have activated a key, and which one did is -the whole question. - -**Abort authority** is the requester's credential and an authorized approver -under the same owner — not "either side" unqualified. State, version and -expiry are re-verified inside the lock, and that state check is exactly the -`pending`/`approved` restriction above. - -**Abort is a credential-authorized operation**, and the server sets -`aborted` and appends the notice **in one transaction**. The notice carries -no end-to-end signature, but it is a server-authenticated control row bound -to the request and its version, admitted through the same dedicated path, so -an ordinary writer cannot inject one. That is what makes immediate -termination safe. Revision 2 argued instead that a forged abort is merely -denial of service — which is a reason to *tolerate* a malicious server, not a -reason to skip authentication. A genuinely unauthenticated notice could only -be a hint, and would have to be confirmed by an authenticated status query -before terminating anything. - -### Observability - -The load-bearing table. Honest-server crash recovery and the malicious-server -SAS guarantee are deliberately separate columns: server-reported status can -reconcile abort and expiry, but it cannot substitute for the SAS, because a -malicious server is exactly the party reporting the status. - -| transition | how device 1 observes | how device 2 observes | fail-closed when unobservable | -|---|---|---|---| -| `pending` created | approver's pending list, authenticated | local; it created the request | — | -| `→ approved` | it performed the CAS | **authenticated request-status query, independent of payload arrival** | show "waiting", never "approved" | -| payload delivered | delivery ack | control row arrives on the stream | remain waiting until the deadline | -| `→ consumed` | **status query** | it accepted the SAS | device 1 shows "delivered, awaiting confirmation" | -| `→ completed` | **status query**, after device 2 acknowledges | it promoted the key | device 1 shows "confirmed; the other device is finishing"; **"done" is shown only when `completed` is actually observed on the status query**, never inferred from delivery, from elapsed time, or from the absence of an error | -| `→ activation_failed` | **status query** | it found the staged material lost and reported it | device 1 keeps showing "the other device is finishing" and offers re-request; it never renders an unobserved outcome as success | -| `→ abandoned` | it declared it, after the completion deadline | often never — the case it exists for is a device that is gone; a device that *returns* sees it on the status query, and appends late-completion evidence if it had in fact promoted | device 1 keeps showing "the other device is finishing" until the completion deadline, then offers to abandon; never before | -| `→ aborted` | status query | **authenticated abort control row on the same path**, plus status query | treat as still pending until the deadline | -| `→ expired` | **server-supplied absolute deadline** | same | at the deadline, stop *starting* operations — do not resolve one already in flight; a request at `consumed`, or with an unreconciled consume, is never expired locally | - -Three consequences, all of which revision 1 lacked: - -- **Request status is queryable independently of the payload.** Without it, - device 2 cannot distinguish "not approved yet" from "approved, delivery in - flight" — states that call for opposite human actions ("wait" versus "go - look at device 1"). On a mobile client, where stream arrival depends on push - and connectivity, these diverge routinely rather than exceptionally. -- **Abort is delivered as a server-authenticated control row on the same - path**, reusing the extension point control messages already established, - and admitted through the same dedicated operation so an ordinary writer - cannot forge one. It terminates the request immediately rather than - prompting the human, and the human re-requests. -- **The deadline is an absolute time supplied by the server**, not a local - countdown from registration. A device that was offline for five minutes - otherwise displays time that does not exist, which is the same class of - defect as the false "expired" above: the human believes a code is valid when - it is not. - -## Outcomes the human sees - -Each is a distinct protocol outcome, because collapsing them produces -misleading text, and one collapse is actively dangerous. - -| outcome | meaning | -|---|---| -| expired | the deadline passed; re-request | -| unknown code | no such pending request (post-authentication only) | -| cap reached | too many pending requests | -| **SAS mismatch** | **the keys differ; treat as an attack** | -| epoch rollback refused | the delivered epoch is older than the canonical one; not a comparison failure | -| undecryptable delivery | addressed to this request but this device's key does not open it — the expected result of substitution | -| conflicting delivery | a *different* envelope arrived for one `request_id`; abort | -| staged material lost | the bundle or the one-time identity is gone but this installation survives; re-request — and if the request had reached `consumed`, report `activation_failed` first, so device 1 stops waiting | -| installation lost | reinstall, keystore loss, or a device that is gone; **nothing can be reported from this side** — device 1 resolves it with `abandoned` | -| activation failed | seen on device 1: the other device is still there and states it did not promote the key; the request is dead, re-request, no rotation needed | -| abandoned | seen on device 1: no completion arrived by the completion deadline and **activation was unknown at that moment**; the request can no longer transition, but the evidence channel stays open — a device that returns later still records that it activated — and if that device is not recoverable and trusted, rotate the epoch | -| delivery after expiry | a valid-looking payload arrived past the deadline; discard and re-request | -| cannot reach the server | distinct from "not approved yet"; waiting does not help | - -**SAS mismatch must not share wording with any of the others.** "Local key -lost" and "undecryptable delivery" both surface as "it would not open", and -if either is worded like a mismatch, a real substitution gets dismissed as -"the key probably got wiped again". - -**Conflicting delivery**: single-use CAS stops a second *approval*, not a -second *envelope*. But "a second envelope aborts" — revision 2's rule — would -break the transport, because re-observing a row is normal: pull retries, -resync, and the approver's own echo all re-present the same delivery, and -at-least-once transport would abort every pairing. - -The two cases are different and must be separated. **Re-observing the same -`(server_seq, wire_id, exact envelope digest)` is exact replay and is accepted -idempotently.** Only a **conflicting** second delivery for the same -`request_id` — a different `wire_id`, digest, recipient, or version — aborts, -because first-wins there would let an attacker race a substituted blob against -the real one. - -That abort is a real `aborted` transition, and it is available because a -conflicting delivery can only be *handled as one* while the request is still -`approved`. **A conflicting envelope observed after `consumed` is rejected and -discarded locally instead**, since the request may no longer be aborted and -there is nothing left to protect: the SAS the human already compared binds the -one envelope that was accepted, so a later blob cannot displace it. It is -still reported as a conflicting delivery, because a second envelope arriving at -all is worth showing the human. - -**Delivery after expiry** is discarded rather than accepted, and the TTL is -not extended and does not pause at approval. A 14th-minute approval whose -payload crosses the deadline is a re-request, not a special case — with a -named outcome so it is never confused with an attack. - -**Epoch rollback** cannot be judged against "an epoch I already hold": a new -device holds none. The canonical epoch snapshot digest and revision chosen by -the trusted live device are bound into the payload and the SAS, and the -requester verifies the hash chain and shape. - -## Durability ordering - -Undefined crash ordering leaves the server and the device disagreeing in -whichever direction the crash falls. The order is: - -1. stage the decrypted bundle durably (`0600`, no-follow); -2. record the human's SAS-match decision durably; -3. CAS an unexpired `approved` to `consumed`; -4. promote the staged material to the local active key **and record - `(request_id, request version, completion pending)` in a durable local - completion outbox — in one commit**; -5. acknowledge `completed` to the server, retrying until it succeeds or the - stored result for that `(operation_id, request)` is re-fetched and found - already recorded, then clear the outbox entry. - -**Step 4 is one commit, not two.** Revision 3 ordered the promote correctly but -gave the acknowledgement no durable owner, so a crash between promoting the key -and sending `completed` left device 2 entirely functional — its key is active, -so nothing on that device would ever retry — while device 1 waited on a state -that could no longer arrive. Writing the outbox entry in the same commit as the -promote is what makes the acknowledgement survive that crash, and what keeps -the two from disagreeing in either direction. The acknowledgement is idempotent -for exactly that reason: after a crash device 2 cannot know whether its last -attempt was received. - -The outbox is also what separates the two terminal reports. An entry recording -a committed promote **forbids** `activation_failed`; staged material that is -gone with no such entry **requires** it. Neither report is a judgement call at -the point of failure. - -A lost consume acknowledgement is re-obtained idempotently for the same -request. A crash after `consumed` but before step 4 resumes from the staged -material; a crash after step 4 resumes from the outbox. Mismatch, abort and -expiry — all of which can occur only before `consumed` — erase the staged -plaintext and the one-time identity, **subject to the reconciliation rule -above**: expiry never erases while a consume is unreconciled, because at that -point the client does not yet know that it is expiry it is handling. The -deadline is re-checked immediately -before the `consumed` CAS and winning that CAS after expiry is prohibited; -steps 4 and 5 are deliberately **not** gated on it, for the reason given under -"State, authority, and observation". - -Revision 1's claim that expiry "leaves no key material behind on either side" -was wrong as written, since the ciphertext remains in the stream. It is scoped -here to the request-specific secrets and staged plaintext, which is what can -actually be erased. - -## Client obligations for a non-addressee - -A `pair-v1` envelope that is not for this client is **semantically inert after -strict validation** — not skipped unvalidated. A non-addressee still validates -the outer schema, bounds, canonical base64, age framing and header bounds, and -stanza grammar, and treats malformed or oversize input as durable protocol -corruption. It does **not** decrypt. Selection is by `(cipher, key_id)` against -a request this client is itself waiting on, so no trial decryption occurs. - -Beyond that it must not report the envelope as an error, must not count it -unread, must never hand it to an agent as message content, and must not -re-emit it. The row stays in the stream; only rendering and dispatch are -suppressed. - -**A client with a background notification path must skip `pair-v1` -unconditionally there** — no decryption attempt, no notification, no handoff -record. On mobile the first code to touch an envelope is an out-of-process -notification extension whose only job is to decrypt and display; left alone it -would post a decryption-failure notice to every device on the team each time -anyone pairs, which is exactly what the inertness rules forbid, one layer -below where those rules were written. The selection rule is a local lookup -that a separate process may be unable to perform, so it must not be required -to: dispatch on `cipher` alone is sufficient, and pairing needs no -notification because the human is already looking at the screen to compare the -SAS. - -This does not weaken the strict-validation rule above; it divides -responsibility. The notification path performs **no validation and no -decryption** — it only declines to act. The strict outer and profile -validation still happens, unconditionally, on the main pull path, which is the -one that decides whether a row is well-formed or is durable protocol -corruption. Skipping by `cipher` alone is safe precisely because it defers -rather than replaces that check. - -## Unchanged - -Zero new cryptography; no new transport (delivery is a control message on the -existing stream, through its own admission path); `key show --reveal-secret` -and `key import` remain permanently for offline and self-hosted setups, with -the SSH one-liner documented as a fallback; and the skeleton stays generic -enough that a QR path can later replace the visual comparison. - -## The status query - -Because so much observability now rests on it, its response is pinned too: - -- a **monotonic request version** alongside the state, counting state - transitions only and frozen once the state is terminal; -- a **monotonic `evidence_revision`**, counting accepted evidence appends; -- an **immutable `expires_at`**; -- an **immutable `completion_deadline`**, present once the request has reached - `consumed` and fixed by that transaction — never recomputed, never extended; -- the delivery identity and digest, when one exists; -- **late-completion evidence**, when any has been appended to an `abandoned` - request, with its arrival time; -- `no-store`. - -A client **rejects a regression in either counter, rejects a state change -carried at an unchanged request version, and rejects an evidence change carried -at an unchanged `evidence_revision`.** The rule is per counter precisely so that -appending evidence to a terminal request is not indistinguishable from a server -mutating one. The absolute deadline is pinned locally from the -registration response and is never extended by a retry or by a later status -response — otherwise a server could keep a request alive by answering -generously. - -## Before implementation - -These are gates on starting the work, not open architecture questions. - -**The code entropy, the SAS entropy, the hash construction and domain -separation, and the attempt-rate limits must be fixed together, as numbers, -in the versioned spec.** Revision 2 deferred them to "the implementation -review" as if they were independent tuning knobs. They are not: the SAS length -that is sufficient depends on the attempt rate and the TTL, and the code -length that is sufficient depends on the cap and the same rate. Choosing any -one of them alone is choosing the others by accident. - -**The Stage 1/2 record kind** that carries the control row (above) must be -agreed with the storage/server owner. - -**The completion deadline and the staged-material retention bound are fixed as -numbers in the same spec, and explicitly not derived from the TTL.** They are -separate quantities answering separate questions — how long device 1 waits -before it may abandon, and how long device 2 holds decrypted material with the -outcome unknown — and the reason both exist is that neither may be allowed to -elapse alongside the pairing deadline. Deriving either from the TTL would -reintroduce the coincidence that U1 was about. - -## Open - -- Migration of the wire shape into a versioned `docs/spec/` profile. -- Console copy that says "paste this" needs rewording now that pairing is the - primary path. diff --git a/docs/design/ref/remote-sync-dogfood.md b/docs/design/ref/remote-sync-dogfood.md deleted file mode 100644 index 30751066..00000000 --- a/docs/design/ref/remote-sync-dogfood.md +++ /dev/null @@ -1,177 +0,0 @@ -# Stage-1 remote sync dogfood - -> Reference only. Delete this document when `integration/remote` merges to -> `main`. - -Stage 1 polls the draft reference server while every `send` still commits to the -local SQLite store first. It is intentionally branch-only and is not installed -by the released core yet. - -Requirements: Node.js 22+, SQLite, jq, base64, and a provisioned team on the -reference server. Connect each device with its own single-use pairing token -before starting the polling engine: - -```sh -printf '%s' "$PAIRING_TOKEN" | scripts/remote.sh connect \ - --endpoint https://sync.example --token-stdin example-team -``` - -`remote.sh connect` stores the non-secret binding under -`teams//config.json` and the device's bearer credential in a separate -`0600` file under `run/remote-credentials/`. The engine reads those artifacts -directly. It does not accept credentials through argv or environment, and it -never passes them to a storage driver child. - -An isolated dogfood client may set `AGMSG_SYNC_CONNECTION_DIR` before both -`remote.sh` and `remote-sync.sh`; its `teams/` and `run/remote-credentials/` -paths are then rooted there instead of in the checkout. This is useful for -two-device rehearsals and must not point at a live fleet's data root. - -For a plaintext-capable binding, no second configure step is needed. Select the -local storage driver and store as usual: - -```sh -export AGMSG_STORAGE_PATH=/path/to/machine-a-store -export AGMSG_STORAGE_DRIVER=sqlite # or jsonl -``` - -Run one push/pull cycle: - -```sh -scripts/remote-sync.sh once --team example-team -``` - -Or poll continuously (five seconds by default): - -```sh -scripts/remote-sync.sh run --team example-team --interval 5 -``` - -After installing a previously missing identity or deliberately changing local -open support, retry durable quarantine without rewinding the transport cursor: - -```sh -scripts/remote-sync.sh reprocess --team example-team --limit 100 -``` - -Reprocessing is explicit. Continuous polling does not repeatedly decrypt a -permanently invalid ciphertext. - -The command emits timestamped JSONL lifecycle events. Set a log file to retain -the exact push/ack/pull/import trace; the file is append-only from the client's -perspective and includes imported plaintext bodies: - -```sh -export AGMSG_SYNC_LOG_FILE=/path/to/stage1-dogfood.jsonl -scripts/remote-sync.sh once --team example-team -``` - -For an `age-v1` binding, install the standard `age` CLI and provision a -freshness-confirmed epoch snapshot outside the message server. Identity files -must be regular files with mode `0600` on POSIX systems. The epoch snapshot is -public -key material and its file must use compact RFC 8785 JCS; the expanded example -below shows its minimum initial-epoch data shape: - -```json -{ - "profile": "age-v1", - "server_instance_id": "018f3f7e-0000-7000-8000-000000000000", - "team_id": "018f3f7e-0000-7000-8000-000000000001", - "epoch_revision": "0", - "writer_generation": "0", - "authorized_writers": ["machine-a"], - "previous_snapshot_sha256": null, - "history": [{ - "epoch_revision": "0", - "effective_from_seq": "1", - "cipher": "age-v1", - "key_id": "epoch-1", - "recipients": ["age1..."] - }] -} -``` - -After connecting and independently confirming the current revision and -lowercase JCS SHA-256 digest with the epoch authority, configure the -encryption-specific state. HTTP authentication still comes only from the -credential created by `remote.sh connect`: - -```sh -export AGMSG_AGE_BIN=/path/to/age # optional when age is already on PATH -export AGMSG_SYNC_TRUST_DIR=/durable/path/agmsg-sync-trust -chmod 600 /secure/path/epoch-1.identity - -scripts/remote-sync.sh configure \ - --team example-team \ - --server https://sync.example \ - --team-id 018f3f7e-0000-7000-8000-000000000001 \ - --minimum-security e2ee-required \ - --cipher age-v1 \ - --age-snapshot /authenticated/path/epoch-snapshot.json \ - --age-checkpoint '0:CONFIRMED_LOWERCASE_SHA256' \ - --age-confirmation operator-live \ - --age-identity epoch-1=/secure/path/epoch-1.identity -``` - -For a rotated team, pass the complete authority-confirmed chain in ascending -revision order, repeating `--age-snapshot` once per compact JCS epoch snapshot. -The checkpoint names the final epoch snapshot: - -```sh -scripts/remote-sync.sh configure \ - --team example-team \ - --server https://sync.example \ - --team-id 018f3f7e-0000-7000-8000-000000000001 \ - --minimum-security e2ee-required \ - --cipher age-v1 \ - --age-snapshot /authenticated/path/epoch-0.json \ - --age-snapshot /authenticated/path/epoch-1.json \ - --age-checkpoint '1:CONFIRMED_LOWERCASE_SHA256' \ - --age-confirmation operator-live \ - --age-identity epoch-1=/secure/path/epoch-1.identity \ - --age-identity epoch-2=/secure/path/epoch-2.identity -``` - -Importing a future epoch snapshot does not activate it. The synchronized -`key_rotated` record is the activation trigger, and its epoch, key ID, -recipient fingerprint, and sequence boundary must all match the provisioned -epoch snapshot. A missing or mismatched epoch snapshot stops synchronization -with an explicit error before the new epoch is used. - -`AGMSG_SYNC_TRUST_DIR` is the retained anti-rollback trust-anchor store. It is -mandatory for `age-v1`, must be outside `AGMSG_STORAGE_PATH`, and must not be -deleted by sync-state reset or local-store replacement. The first configuration -requires `--age-confirmation operator-live`, which records that the operator -verified the exact revision and digest through a separate live channel. A lower -revision, same-revision/different-digest epoch snapshot, broken predecessor -hash, or missing revision is rejected even if the ordinary sync config has -been removed. - -Only the public recipient list crosses the storage-driver seam. The private -identity path stays in the engine configuration and is used only while opening -pulled envelopes. Joining an established rotated binding or rotating an active -binding requires complete chain verification, quiesce, drain, a server -authorization fence, and the fresh-boundary procedure in the -[`age-v1` profile](../../spec/ref/age-v1-profile.md#multi-writer-cutover-protocol), which -is not yet automated by this client. - -For `age-v1`, lifecycle logs omit imported plaintext fields by default. Set -`AGMSG_SYNC_LOG_PLAINTEXT=1` only when the log destination is intentionally -trusted to contain decrypted message content. Plaintext bindings retain the -existing body-inclusive dogfood trace. - -`AGMSG_SYNC_DRIVER`, `AGMSG_SYNC_CIPHER_HELPER`, and `AGMSG_AGE_BIN` select -locally executable code and are trusted-operator settings. Do not accept these -values from message content, remote responses, or untrusted project config. - -For a single-host two-machine simulation, repeat configuration with two -different `AGMSG_STORAGE_PATH` directories and the same immutable remote team -ID. A message sent into store A is pushed by A and imported by B; the pull echo -on A only confirms its existing local-to-wire mapping and does not create a -second local message. - -HTTP 410 (`resync-required`) is terminal. Stage 1 never rewinds or resets the -transport cursor automatically. SQLite and JSONL advertise -`capabilities=stage1-sync`; other drivers that do not advertise it remain valid -local-only drivers. diff --git a/docs/security.ja.md b/docs/security.ja.md index d71e7ab4..24384558 100644 --- a/docs/security.ja.md +++ b/docs/security.ja.md @@ -85,7 +85,7 @@ envelope を見て、保存し、再生・並べ替え・破棄でき、自分 う類のものではない: ``` -docs/spec/ref/age-v1-profile.md:342-345 +docs/spec/age-v1-profile.md:342-345 "Recipient public keys, private identities, recipient-set manifests, and epoch history are provisioned outside the message server over an authenticated, freshness-proving channel. Copying only the current private @@ -128,7 +128,7 @@ server/src/protocol.ts:68 cipher: z.string().regex(cipherPattern), は、鍵を*扱う*サーバである。 鍵がそもそもそこに無いという主張は、この grep の発見ではなく**仕様上の要件**であ -る —— 上で引用した `docs/spec/ref/age-v1-profile.md:342-345`。両者は互いを支え、ど +る —— 上で引用した `docs/spec/age-v1-profile.md:342-345`。両者は互いを支え、ど ちらも他方を置き換えない: 仕様は鍵が別の場所で供給されると述べ、この検索はコードに それが届いたとして使うものが無いと述べる。 @@ -193,14 +193,14 @@ of scope」はこの文書が調べていないことを意味する —— 「n | 特性 | 状態 | 根拠 | |---|---|---| -| メッセージ内容の秘匿性 | **Provided** | age X25519 暗号化。identity はサーバの外に在る (`docs/spec/ref/age-v1-profile.md:342-345`) | -| メッセージ内容の完全性 | **Provided** | **inherited** —— age 自身の AEAD であり、このツリーではなく age フォーマットの性質である。プロファイルは意図的に第2の AEAD 層を足していない (`docs/spec/ref/age-v1-profile.md:13`) ので、保証は age のものであり、確かめるには age を読む。サーバの digest は*その機構ではない* —— 下記参照 | -| メッセージ内容の偽造不可能性 | **Provided** | 受信者の公開鍵を要し、仕様はそれをサーバの外に置く (`docs/spec/ref/age-v1-profile.md:342-345`, `:58-63`) | +| メッセージ内容の秘匿性 | **Provided** | age X25519 暗号化。identity はサーバの外に在る (`docs/spec/age-v1-profile.md:342-345`) | +| メッセージ内容の完全性 | **Provided** | **inherited** —— age 自身の AEAD であり、このツリーではなく age フォーマットの性質である。プロファイルは意図的に第2の AEAD 層を足していない (`docs/spec/age-v1-profile.md:13`) ので、保証は age のものであり、確かめるには age を読む。サーバの digest は*その機構ではない* —— 下記参照 | +| メッセージ内容の偽造不可能性 | **Provided** | 受信者の公開鍵を要し、仕様はそれをサーバの外に置く (`docs/spec/age-v1-profile.md:342-345`, `:58-63`) | | ピア認証 | **Not provided** | プロトコルに鍵と人の結びつきがない (`docs/design/remote-sync.md:93-94`) | | メタデータの秘匿性 | **Not provided** | **assumption**、しかも容易な種類のもの: サーバは envelope の宛先と時刻で配送し順序付けるので、それらを読む。引用を示さないのは、ツリーの中にそれを規則として述べたものが無いからである —— サーバが自分の仕事をすることから従う | -| 前方秘匿性 (攻撃者 C) | **Not provided** | recipient set は epoch 単位で不変 (`docs/spec/ref/age-v1-profile.md:88`)。後に侵害された identity はその epoch の履歴を復号する | +| 前方秘匿性 (攻撃者 C) | **Not provided** | recipient set は epoch 単位で不変 (`docs/spec/age-v1-profile.md:88`)。後に侵害された identity はその epoch の履歴を復号する | | 侵害後の回復 (攻撃者 C) | **Partial, by rotation** | 新しい epoch は新しい recipient set である。journal は回転と fingerprint を記録し、鍵は決して記録しない (`docs/design/remote-sync.md:103-104`) | -| ダウングレード耐性 (サーバによる強制) | **Provided by the spec's stanza rules** | Scrypt、SSH、plugin、その他すべての非 X25519 stanza が除外される (`docs/spec/ref/age-v1-profile.md:58-63`) | +| ダウングレード耐性 (サーバによる強制) | **Provided by the spec's stanza rules** | Scrypt、SSH、plugin、その他すべての非 X25519 stanza が除外される (`docs/spec/age-v1-profile.md:58-63`) | | ダウングレード耐性 (クライアントが `cipher: none` を受理) | **Provided by every caller in the tree** | 2つの `configure` 呼び出しはどちらも `--cipher age-v1` と `--minimum-security e2ee-required` を一緒に渡し (`scripts/remote.sh:1280`, `:1758`)、3つ目は存在しない。拒否は `scripts/internal/remote-sync.mjs:1686`。`configure` を直接 `plaintext-allowed` で叩いた場合にのみ外れる | ### 前方秘匿性について @@ -209,7 +209,7 @@ of scope」はこの文書が調べていないことを意味する —— 「n は**不変の** recipient set を指す: ``` -docs/spec/ref/age-v1-profile.md:88 +docs/spec/age-v1-profile.md:88 "A `key_id` identifies an immutable set of X25519 recipients and its private …" ``` @@ -400,7 +400,7 @@ scripts/remote.sh:1285 --cipher age-v1 ## この文書が引用する仕様の古さについて -`docs/spec/ref/age-v1-profile.md` は **"Status: proposed (dogfood profile)"** と記 +`docs/spec/age-v1-profile.md` は **"Status: proposed (dogfood profile)"** と記 されており、最後に触れられたのは 2026-07-27、それを参照資料として整理したコミット `1a56d8e docs: file superseded work as reference` による。`ref/` の下に在り、その README はこう明言している: *"Nobody is building toward anything in a `ref/` @@ -437,9 +437,9 @@ ASVS を起点に作業する読者のための相互参照として提供する | ASVS V6 area | この文書のどこが扱っているか | |---|---| | V6.1 Data classification | 「特性の表を読む前に」の項目 1 と 3 | -| V6.2 Algorithms | 特性の表。`docs/spec/ref/age-v1-profile.md:58-63`(X25519 のみ) | +| V6.2 Algorithms | 特性の表。`docs/spec/age-v1-profile.md:58-63`(X25519 のみ) | | V6.2 Integrity | 「`envelopeDigest` は署名ではない」 | -| V6.4 Secret management | `docs/spec/ref/age-v1-profile.md:342-345`(供給はサーバの外) | +| V6.4 Secret management | `docs/spec/age-v1-profile.md:342-345`(供給はサーバの外) | | V6.4 Key rotation | 特性の表の侵害後の回復。`docs/design/remote-sync.md:103-104` | ## この文書の確かめ方 @@ -448,7 +448,7 @@ ASVS を起点に作業する読者のための相互参照として提供する 点でもよい —— コードはどちらでも同じである: ``` -sed -n '342,345p' docs/spec/ref/age-v1-profile.md +sed -n '342,345p' docs/spec/age-v1-profile.md sed -n '221,238p' server/src/protocol.ts ``` diff --git a/docs/security.md b/docs/security.md index 896e5f17..976b3768 100644 --- a/docs/security.md +++ b/docs/security.md @@ -88,7 +88,7 @@ That placement is a **specification requirement**, not an artefact of how the current server happens to be written: ``` -docs/spec/ref/age-v1-profile.md:342-345 +docs/spec/age-v1-profile.md:342-345 "Recipient public keys, private identities, recipient-set manifests, and epoch history are provisioned outside the message server over an authenticated, freshness-proving channel. Copying only the current private @@ -135,7 +135,7 @@ server that happens to hold key material it never names. It rules out a server that *works with* keys. The claim that keys are not there at all is a **specification requirement**, not -a finding of this grep — `docs/spec/ref/age-v1-profile.md:342-345`, quoted +a finding of this grep — `docs/spec/age-v1-profile.md:342-345`, quoted above. The two support each other and neither replaces the other: the spec says the keys are provisioned elsewhere, and this search says the code has nothing that would use them if they arrived. @@ -202,14 +202,14 @@ table is a claim about adversary B. | Property | Status | Basis | |---|---|---| -| Confidentiality of message contents | **Provided** | age X25519 encryption; identities are outside the server (`docs/spec/ref/age-v1-profile.md:342-345`) | -| Integrity of message contents | **Provided** | **inherited** — age's own AEAD, a property of the age format rather than of this tree. The profile deliberately adds no second AEAD layer (`docs/spec/ref/age-v1-profile.md:13`), so the guarantee is age's and is checked by reading age, not this repository. The server's digest is *not* the mechanism — see below | -| Unforgeability of message contents | **Provided** | Requires recipients' public keys, which the spec places outside the server (`docs/spec/ref/age-v1-profile.md:342-345`, `:58-63`) | +| Confidentiality of message contents | **Provided** | age X25519 encryption; identities are outside the server (`docs/spec/age-v1-profile.md:342-345`) | +| Integrity of message contents | **Provided** | **inherited** — age's own AEAD, a property of the age format rather than of this tree. The profile deliberately adds no second AEAD layer (`docs/spec/age-v1-profile.md:13`), so the guarantee is age's and is checked by reading age, not this repository. The server's digest is *not* the mechanism — see below | +| Unforgeability of message contents | **Provided** | Requires recipients' public keys, which the spec places outside the server (`docs/spec/age-v1-profile.md:342-345`, `:58-63`) | | Peer authentication | **Not provided** | No key-to-person binding in the protocol (`docs/design/remote-sync.md:93-94`) | | Metadata confidentiality | **Not provided** | **assumption**, and the easy kind: the server routes and orders by the envelope's addressing and timing, so it reads them. No citation is offered because nothing in the tree states it as a rule — it follows from the server doing its job | -| Forward secrecy (adversary C) | **Not provided** | Recipient sets are per-epoch and immutable (`docs/spec/ref/age-v1-profile.md:88`); an identity that is later compromised decrypts that epoch's history | +| Forward secrecy (adversary C) | **Not provided** | Recipient sets are per-epoch and immutable (`docs/spec/age-v1-profile.md:88`); an identity that is later compromised decrypts that epoch's history | | Post-compromise recovery (adversary C) | **Partial, by rotation** | A new epoch is a new recipient set. The journal records the rotation and a fingerprint, never the key (`docs/design/remote-sync.md:103-104`) | -| Downgrade resistance (server-forced) | **Provided by the spec's stanza rules** | Scrypt, SSH, plugin and every other non-X25519 stanza are excluded (`docs/spec/ref/age-v1-profile.md:58-63`) | +| Downgrade resistance (server-forced) | **Provided by the spec's stanza rules** | Scrypt, SSH, plugin and every other non-X25519 stanza are excluded (`docs/spec/age-v1-profile.md:58-63`) | | Downgrade resistance (client accepting `cipher: none`) | **Provided by every caller in the tree** | Both `configure` calls pass `--cipher age-v1` and `--minimum-security e2ee-required` together (`scripts/remote.sh:1280`, `:1758`), and there is no third. The refusal is `scripts/internal/remote-sync.mjs:1686`. Removable only by invoking `configure` directly with `plaintext-allowed` | ### On forward secrecy @@ -218,7 +218,7 @@ table is a claim about adversary B. `key_id` names an **immutable** recipient set: ``` -docs/spec/ref/age-v1-profile.md:88 +docs/spec/age-v1-profile.md:88 "A `key_id` identifies an immutable set of X25519 recipients and its private …" ``` @@ -416,7 +416,7 @@ measured; see "How a deployment ends up on `e2ee-required`" above.)* ## On the age of the specification this cites -`docs/spec/ref/age-v1-profile.md` is marked **"Status: proposed (dogfood +`docs/spec/age-v1-profile.md` is marked **"Status: proposed (dogfood profile)"** and was last touched on 2026-07-27, by the commit that filed it as reference material — `1a56d8e docs: file superseded work as reference`. It lives under `ref/`, whose README says plainly: *"Nobody is building toward anything in @@ -455,9 +455,9 @@ the sections above are the claim. | ASVS V6 area | Where this document addresses it | |---|---| | V6.1 Data classification | "Read this before the properties table", items 1 and 3 | -| V6.2 Algorithms | Properties table; `docs/spec/ref/age-v1-profile.md:58-63` (X25519 only) | +| V6.2 Algorithms | Properties table; `docs/spec/age-v1-profile.md:58-63` (X25519 only) | | V6.2 Integrity | "`envelopeDigest` is not a signature" | -| V6.4 Secret management | `docs/spec/ref/age-v1-profile.md:342-345` (provisioning is outside the server) | +| V6.4 Secret management | `docs/spec/age-v1-profile.md:342-345` (provisioning is outside the server) | | V6.4 Key rotation | Properties table, post-compromise recovery; `docs/design/remote-sync.md:103-104` | ## How to check this document @@ -466,7 +466,7 @@ Every citation is `path:line`. To verify one, against this branch's head or the branch point — the code is the same at both: ``` -sed -n '342,345p' docs/spec/ref/age-v1-profile.md +sed -n '342,345p' docs/spec/age-v1-profile.md sed -n '221,238p' server/src/protocol.ts ``` diff --git a/docs/spec/ref/age-v1-profile.md b/docs/spec/age-v1-profile.md similarity index 98% rename from docs/spec/ref/age-v1-profile.md rename to docs/spec/age-v1-profile.md index c1e2996e..c389d329 100644 --- a/docs/spec/ref/age-v1-profile.md +++ b/docs/spec/age-v1-profile.md @@ -1,12 +1,12 @@ # agmsg `age-v1` cipher profile -**Status:** proposed (dogfood profile) +**Status:** current **Profile identifier:** `age-v1` **Envelope version:** `1` This document pins the first encrypted envelope profile for the agmsg remote sync protocol. It extends the opaque envelope in -[`server/spec/v1.md`](../../../server/spec/v1.md) without changing the HTTP message +[`server/spec/v1.md`](../../server/spec/v1.md) without changing the HTTP message schema or the Stage-1 storage-driver durability boundary. `age-v1` is a standard binary [age v1 file][age-format], encrypted to native @@ -357,7 +357,9 @@ by the HTTP v1 three-layer state model. protected with platform-appropriate file permissions. The reference client validates the exact native identity bytes and passes those same bytes to age over a private pipe, preventing a path substitution between validation and - open. HTTP bearer credentials and age identities are separate secrets. + open. The transport carries no secret of its own (see + [the HTTP API](../../server/spec/v1.md)), so an age identity is the only + secret on this path and reaching the server never substitutes for holding one. - The profile does not define padding. Team relationship, key epoch, age-file length, approximate recipient count and rotation pattern, server arrival time, traffic frequency, and sequence remain visible. A `key_id` is public diff --git a/docs/spec/driver-interface.md b/docs/spec/driver-interface.md index a5bc7588..648b089e 100644 --- a/docs/spec/driver-interface.md +++ b/docs/spec/driver-interface.md @@ -337,11 +337,11 @@ reevaluation without rewinding transport. It uses the Stage-1 specification's st so one explicit engine invocation reaches every candidate without an early permanent failure starving later records. The complete framing, record schemas, crash boundaries, and future reserved operation names are defined by -[Stage-1 synchronization specification](ref/stage-1-remote-sync.md). +[Stage-1 synchronization specification](stage-1-remote-sync.md). A driver may additionally advertise `stage1-resync` and implement the explicit operator recovery contract from the -[retention-gap resynchronization specification](ref/retention-gap-resynchronization.md): +[retention-gap resynchronization specification](retention-gap-resynchronization.md): ```text storage_sync_resync_status @@ -358,7 +358,7 @@ audit, and result objects, including canonical sequence arithmetic and duplicate/unknown-field rejection. The independent Stage-2 extension from the -[read-state synchronization specification](ref/read-state-synchronization.md) is advertised as +[read-state synchronization specification](read-state-synchronization.md) is advertised as `capabilities=stage1-sync,stage2-read-state` and adds: ```text diff --git a/docs/spec/ref/read-state-synchronization.md b/docs/spec/read-state-synchronization.md similarity index 98% rename from docs/spec/ref/read-state-synchronization.md rename to docs/spec/read-state-synchronization.md index d98bedfc..ce7b992e 100644 --- a/docs/spec/ref/read-state-synchronization.md +++ b/docs/spec/read-state-synchronization.md @@ -1,10 +1,10 @@ # Stage-2 read-state synchronization specification -**Status:** dogfood specification +**Status:** current **Last updated:** 2026-07-25 The irreversible read-state semantics behind this contract are recorded in -[ADR 0006: Composite read-state frontier](../../adr/ref/0006-composite-read-state-frontier.md). +[ADR 0006: Composite read-state frontier](../adr/0006-composite-read-state-frontier.md). ## Context @@ -347,7 +347,7 @@ still does not inspect recipients or message bodies. ## References -- [ADR 0003: storage-axis ABI](../../adr/0003-storage-axis-driver-abi-and-scope.md) +- [ADR 0003: storage-axis ABI](../adr/0003-storage-axis-driver-abi-and-scope.md) - [Stage-1 remote synchronization](stage-1-remote-sync.md) -- [ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md) -- [HTTP API v1](../../../server/spec/v1.md) +- [ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md) +- [HTTP API v1](../../server/spec/v1.md) diff --git a/docs/spec/ref/README.md b/docs/spec/ref/README.md deleted file mode 100644 index 60615cfe..00000000 --- a/docs/spec/ref/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Reference - -**Nobody is building toward anything in a `ref/` directory.** These are kept -for what they worked out, not as a description of the product or a plan for it. - -Some were reviewed, some reviewed many times. A passed review says the -reasoning holds together — not that the design was adopted. Some say -"implemented", describing code that exists rather than a commitment to keep it. -Read every normative-sounding sentence here as *"if this had been adopted, it -would have worked like this."* - -Do not implement from a document under `ref/`. Do not cite one as the reason -something is the way it is. Do not build a runbook or a README step from one. - -Work that is actually being built toward lives in the parent directory, whether -or not any code exists yet. - -## Why this exists - -A document here recorded that the shipped onboarding creates a team in the -opposite order from the intended one, and named the schema constraints causing -it. Work continued against the shipped order for two days — nobody treated the -document as a gate, because a status line inside a file is connected to -nothing, and the files beside it described working code. A runbook was then -written teaching a command that the same directory said should not exist. - -Splitting by directory makes the distinction visible from the path, before the -file is opened. - -## Using these - -They remain worth reading. Constraints, failure modes, and wire shapes worked -out here often survive a change of direction even when the design around them -does not — that reasoning is why they were kept rather than deleted. Take the -argument; do not take the conclusion as current. diff --git a/docs/spec/ref/retention-gap-resynchronization.md b/docs/spec/retention-gap-resynchronization.md similarity index 96% rename from docs/spec/ref/retention-gap-resynchronization.md rename to docs/spec/retention-gap-resynchronization.md index 8e72ed95..c40a2e38 100644 --- a/docs/spec/ref/retention-gap-resynchronization.md +++ b/docs/spec/retention-gap-resynchronization.md @@ -1,10 +1,10 @@ # Operator-approved retention-gap resynchronization specification -**Status:** dogfood specification +**Status:** current **Last updated:** 2026-07-25 The irreversible cursor and audit semantics behind this operation are recorded -in [ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md). +in [ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md). ## Context @@ -203,6 +203,6 @@ live-row count without logging envelopes or credentials. - [Stage-1 remote synchronization](stage-1-remote-sync.md) - [Stage-2 read-state synchronization](read-state-synchronization.md) -- [ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md) -- [ADR 0006: Composite read-state frontier](../../adr/ref/0006-composite-read-state-frontier.md) -- [HTTP API v1](../../../server/spec/v1.md) +- [ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md) +- [ADR 0006: Composite read-state frontier](../adr/0006-composite-read-state-frontier.md) +- [HTTP API v1](../../server/spec/v1.md) diff --git a/docs/spec/ref/server-opaque-envelope.md b/docs/spec/server-opaque-envelope.md similarity index 96% rename from docs/spec/ref/server-opaque-envelope.md rename to docs/spec/server-opaque-envelope.md index 3561b385..17fc0019 100644 --- a/docs/spec/ref/server-opaque-envelope.md +++ b/docs/spec/server-opaque-envelope.md @@ -1,10 +1,10 @@ # Cipher-independent opaque-envelope server specification -**Status:** dogfood specification +**Status:** current **Last updated:** 2026-07-25 The irreversible architectural decision behind this schema is recorded in -[ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md). +[ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md). ## Context diff --git a/docs/spec/ref/stage-1-remote-sync.md b/docs/spec/stage-1-remote-sync.md similarity index 93% rename from docs/spec/ref/stage-1-remote-sync.md rename to docs/spec/stage-1-remote-sync.md index 8b15e104..26d0ba8d 100644 --- a/docs/spec/ref/stage-1-remote-sync.md +++ b/docs/spec/stage-1-remote-sync.md @@ -1,10 +1,10 @@ # Stage-1 local-first remote synchronization specification -**Status:** dogfood specification +**Status:** current **Last updated:** 2026-07-25 The irreversible architectural decisions behind this contract are recorded in -[ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md). +[ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md). ## Context @@ -34,9 +34,13 @@ storage_sync_apply_pull [] ``` -The SQLite driver is the Stage-1 implementation. Drivers that do not advertise -the extension remain valid local-only drivers. Core must fail clearly rather -than emulate these durability operations outside an unsupported driver. +Two bundled drivers implement it. SQLite advertises +`stage1-sync,stage1-resync,stage2-read-state`; JSONL advertises `stage1-sync` +alone, so the recovery operation below is genuinely optional rather than +optional in name — a client asking for it against JSONL is refused for want of +the advertised capability, not left to discover a missing command. Drivers that +advertise no extension remain valid local-only drivers. Core must fail clearly +rather than emulate these durability operations outside an unsupported driver. The binding is keyed by immutable `server_instance_id`, the server's stable team/stream ID, and protocol version. Endpoint URL is deliberately absent: the @@ -90,7 +94,7 @@ It contains the engine's validated envelope selection and capability limits, but no credentials. The ABI is cipher-neutral: the driver creates the canonical envelope selected by the binding configuration. `none` is the default profile. The optional `age-v1` profile defined in -[`../spec/ref/age-v1-profile.md`](../../spec/ref/age-v1-profile.md) performs its +[`../spec/age-v1-profile.md`](age-v1-profile.md) performs its encrypt-once operation at this same boundary. Prepare receives only the public recipient manifest; age identity files remain in the HTTP engine's open path and never cross the storage-driver boundary. @@ -234,8 +238,8 @@ transport cursor automatically. ## References -- [HTTP API v1](../../../server/spec/v1.md) -- [ADR 0003: storage-axis ABI and scope](../../adr/0003-storage-axis-driver-abi-and-scope.md) +- [HTTP API v1](../../server/spec/v1.md) +- [ADR 0003: storage-axis ABI and scope](../adr/0003-storage-axis-driver-abi-and-scope.md) - [Retention-gap resynchronization](retention-gap-resynchronization.md) -- [ADR 0005: Remote synchronization contract](../../adr/ref/0005-remote-sync-contract.md) +- [ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md) - Issue #441 (local-first cross-machine replication proposal) diff --git a/scripts/drivers/storage/sqlite-sync.sh b/scripts/drivers/storage/sqlite-sync.sh index 3027e7c7..39349889 100644 --- a/scripts/drivers/storage/sqlite-sync.sh +++ b/scripts/drivers/storage/sqlite-sync.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Optional Stage-1 remote synchronization extension for the SQLite driver. -# See docs/spec/ref/stage-1-remote-sync.md. All bulk input/output is JSONL. +# See docs/spec/stage-1-remote-sync.md. All bulk input/output is JSONL. _sqlite_sync_uuid4() { local h n variant diff --git a/scripts/key.sh b/scripts/key.sh index 4a5c920f..bfd25863 100755 --- a/scripts/key.sh +++ b/scripts/key.sh @@ -10,7 +10,7 @@ set -euo pipefail # key.sh rotate [] # # Team-scoped end-to-end encryption key management (age-v1 profile, -# docs/spec/ref/age-v1-profile.md). Scope: initial single-writer onboarding +# docs/spec/age-v1-profile.md). Scope: initial single-writer onboarding # (generate the very first key, import one obtained out-of-band, or announce a # replacement through the team journal). # Authority-confirmed epoch snapshots are imported separately through diff --git a/scripts/remote-sync.sh b/scripts/remote-sync.sh index f46f9790..79fda219 100755 --- a/scripts/remote-sync.sh +++ b/scripts/remote-sync.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Stage-1 polling synchronization client (dogfood; docs/spec/ref/stage-1-remote-sync.md). +# Stage-1 polling synchronization client (dogfood; docs/spec/stage-1-remote-sync.md). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/server/spec/v1.md b/server/spec/v1.md index 320bc721..f76b8094 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -237,7 +237,7 @@ profiles, capability handling, key lifecycle, authenticated binding rules, and padding policy. Those contracts are not defined by this HTTP base profile. The envelope boundary keeps HTTP message columns/endpoints stable; client crypto contracts are not claimed to remain unchanged. The first separately pinned -encrypted profile is [`age-v1`](../../docs/spec/ref/age-v1-profile.md). +encrypted profile is [`age-v1`](../../docs/spec/age-v1-profile.md). Any authenticated-encryption profile MUST cryptographically bind this wire `id` as its message-UUID context. A local driver ID MUST NOT be used for that @@ -633,7 +633,7 @@ The client MUST enter a terminal `resync-required` state, stop incremental sync, and surface operator action. It MUST NOT reset the cursor or discard local state automatically. V1 provides no full-snapshot operation. A client MAY offer the explicit, operator-approved -[retention-gap recovery](../../docs/spec/ref/retention-gap-resynchronization.md): it +[retention-gap recovery](../../docs/spec/retention-gap-resynchronization.md): it revalidates this 410 against a fresh authenticated capability snapshot, records the unavailable interval durably, and advances only its transport cursor to the exact authenticated floor. Normal polling MUST NOT invoke that operation. @@ -837,7 +837,7 @@ floor rather than creating a permanent retained-prefix hole. The full local composite-frontier, exact promotion, quarantine separation, pagination, and limit-recovery rules are normative in -[read-state synchronization specification](../../docs/spec/ref/read-state-synchronization.md). +[read-state synchronization specification](../../docs/spec/read-state-synchronization.md). ## `GET /v1/health` From 77a3abaac15b1833046f36dfe3722bbfa0f669b6 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 15:21:24 -0700 Subject: [PATCH 4/6] docs(spec): name the age-v1 profile in the link, not its old path The rewrite fixed the href and left the label reading ../spec/age-v1-profile.md, a path that no longer exists. A link checker reads the target and never the text, so this is the shape it cannot see; swept the tree for label/href basename disagreement and this was the only one. --- docs/spec/stage-1-remote-sync.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec/stage-1-remote-sync.md b/docs/spec/stage-1-remote-sync.md index 26d0ba8d..5c01c6c2 100644 --- a/docs/spec/stage-1-remote-sync.md +++ b/docs/spec/stage-1-remote-sync.md @@ -94,7 +94,7 @@ It contains the engine's validated envelope selection and capability limits, but no credentials. The ABI is cipher-neutral: the driver creates the canonical envelope selected by the binding configuration. `none` is the default profile. The optional `age-v1` profile defined in -[`../spec/age-v1-profile.md`](age-v1-profile.md) performs its +[`age-v1` profile](age-v1-profile.md) performs its encrypt-once operation at this same boundary. Prepare receives only the public recipient manifest; age identity files remain in the HTTP engine's open path and never cross the storage-driver boundary. From be63ee920d49dc84833909f0aee7942b2ac7ac2c Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 21:08:03 -0700 Subject: [PATCH 5/6] docs(adr): keep 0003 to its decisions, and drop the implementation from it It named the drivers, the tools they shell out to, the releases each was phased into, and the issue numbers implementing them. None of that is the decision, and all of it dates: one phase never shipped, so the document described a plan rather than a boundary. Kept: the locked storage_* ABI, the use-case contract with its opaque driver-issued cursor, and the scope boundary that leaves registry and run-state outside the storage axis. The third decision was written around one specific backend; it now states the boundary itself, which is what would survive that backend being swapped. Removed a consequences paragraph tracking a live call-site gap. That belongs in an issue, not in a decision record, and it is reported separately rather than deleted quietly. Same rule applied to what this branch added elsewhere: the Stage-1 spec no longer names which bundled driver advertises which capability, only that capabilities are advertised separately, and ADR 0005 hands the envelope key field name back to the protocol spec that defines it. --- .../0003-storage-axis-driver-abi-and-scope.md | 106 ++++++++---------- docs/adr/0005-remote-sync-contract.md | 6 +- docs/spec/stage-1-remote-sync.md | 19 ++-- 3 files changed, 58 insertions(+), 73 deletions(-) diff --git a/docs/adr/0003-storage-axis-driver-abi-and-scope.md b/docs/adr/0003-storage-axis-driver-abi-and-scope.md index feb7f308..77d5f8f4 100644 --- a/docs/adr/0003-storage-axis-driver-abi-and-scope.md +++ b/docs/adr/0003-storage-axis-driver-abi-and-scope.md @@ -1,90 +1,76 @@ # ADR 0003: Storage axis — driver ABI, contract shape, and scope boundary -**Status:** proposed (draft — subject to change) +**Status:** proposed **Date:** 2026-06-24 **Deciders:** @fujibee ## Context -1.1.0 shipped the axis-generic driver registry and external-plugin opt-in -([ADR 0002](0002-driver-discovery-and-plugin-opt-in.md)). 1.1.1 implements the -**storage axis** — the message store, made pluggable — with drivers `sqlite` -(default), `jsonl`+`duckdb`, and `redis`. Before writing code, three -architectural questions needed locking. An independent design pass (codex, this -session) converged with the earlier Fugu-demo design (codex + gemini) and -corrected an initial lean toward a subcommand ABI; this ADR records the -converged decisions. ADRs are revisable, so it reaffirms or tightens -[ADR 0001](0001-storage-driver-pluginization.md) where that is the better choice. +[ADR 0002](0002-driver-discovery-and-plugin-opt-in.md) established the +axis-generic driver registry and external-plugin opt-in. Making the message +store pluggable needs three architectural questions locked before any driver is +written, because each of them is expensive to reverse once a store exists: +what a driver is allowed to expose, what core is allowed to ask for, and where +the storage axis stops. + +An independent design pass converged with an earlier one and corrected an +initial lean toward a subcommand ABI; this ADR records the converged decisions. +ADRs are revisable, so it reaffirms or tightens +[ADR 0001](0001-storage-driver-pluginization.md) where that is the better +choice. ## Decision 1. **Drivers stay sourced bash, behind a *locked* ABI.** Keep ADR 0001's sourced-function model, but tighten it: a driver exposes only the `storage_*` domain operations and must not leak SQL fragments, file paths, or backend - cursors to core. Non-bash backends are reached by a thin bash facade that - shells out (duckdb, redis-cli, a helper). A subcommand + JSONL-pipe protocol - is reserved as an *internal* form a facade may exec later if a driver truly - can't be bash — it is **not** promoted to the core ABI now. + cursors to core. A backend that is not bash is reached by a thin bash facade + that shells out to it. A subcommand + JSONL-pipe protocol is reserved as an + *internal* form a facade may exec later if a driver truly cannot be bash — it + is **not** promoted to the core ABI now. 2. **The contract abstracts use-cases, not queries.** Core calls domain operations (send / list-unread / mark-read / watch-after / history), never a query language. Two consequences: (a) the watch/check-inbox replay checkpoint is an **opaque, driver-issued delivery cursor**, kept separate from read - state — core never compares it, so it absorbs sqlite int ids, UUIDv7, Redis - stream ids, and JSONL offsets; (b) read-marking is recipient-scoped and - idempotent. This removes the `id > watermark` (integer-id) assumption that - currently lives in core and would break on UUIDv7 / Redis streams. The - canonical export/import format is a JSONL event log. - -3. **Redis (1.1.2) is a message store only.** The team registry and run-state - (pidfiles, watch watermarks, actas locks, ready sentinels) are *not* moved - onto the network with it — those carry distributed-lease / TTL / clock / - orphan-reclaim concerns beyond the storage axis. Multi-host coordination, if - wanted, becomes a separate **coordination axis** under its own ADR. Redis - enters as a remote message bus, not as shared everything. + state — core never compares it, so a backend may order by an integer, a + time-ordered id, or a byte offset in an append log without core knowing + which; (b) read-marking is recipient-scoped and idempotent. This removes any + `id > watermark` assumption from core, which would otherwise hold only for + backends whose ids happen to be comparable integers. The canonical + export/import format is a JSONL event log. -**Phasing:** 1.1.1 = storage facade + `sqlite` driver + `jsonl`(+`duckdb`) -driver (duckdb an opt-in query accelerator; default install stays bash + -sqlite). 1.1.2 = `redis` (message store). +3. **The storage axis is a message store, and stops there.** The team registry + and run-state — pidfiles, watch watermarks, actas locks, ready sentinels — + are not part of it, and a driver that moves the message store onto a network + does not carry them along. Those raise distributed-lease, TTL, clock, and + orphan-reclaim concerns that belong to a **coordination axis** under its own + ADR, if multi-host coordination is ever wanted. ## Alternatives considered - **Subcommand + JSONL pipe as the core driver ABI.** Cleaner process boundary - and language-agnostic, but the inner sqlite3/duckdb/redis-cli process runs - inside the driver regardless, so piping the core↔driver boundary adds - call-path complexity (and an extra fork on the unread/insert hot path) before - any real benefit. Independent codex and the Fugu design both landed on sourced; - kept as a deferred internal-impl option. + and language-agnostic, but a non-bash backend runs its own process inside the + driver regardless, so piping the core↔driver boundary adds call-path + complexity (and an extra fork on the unread/insert hot path) before any real + benefit. Two independent design passes both landed on sourced; kept as a + deferred internal-implementation option. - **Structured query params with a single core-comparable watermark.** Rejected the comparable watermark — any cursor core can compare leaks the backend's id scheme. The opaque-cursor decision generalizes it. -- **Redis as full shared state (messages + registry + coordination).** Rejected - for 1.1.2: balloons into distributed coordination; split to a future axis. +- **A networked store as full shared state (messages + registry + + coordination).** Rejected: it balloons into distributed coordination, which + decision 3 splits to a future axis. ## Consequences -- Positive: one ABI serves sqlite / jsonl-duckdb / redis with no core changes; - opaque cursor + use-case contract make a new backend a self-contained driver; - default install unchanged; aligns with ADR 0002's registry + opt-in. -- Negative: core code that assumes the integer `messages.id` / `read_at` column - must move behind the contract before any non-sqlite driver works (the bulk of - 1.1.1 — #203/#204/#206). Sourced drivers keep ADR 0002's trust concern, - mitigated by opt-in + the `storage_*` prefix discipline. -- Known gap (not yet migrated, tracked for a follow-up): `rename.sh` / - `rename-team.sh` rewrite historical `from_agent`/`to_agent`/`team` values by - running `UPDATE` directly against the sqlite driver's own `messages` and - `events` tables — bypassing the contract entirely, guarded only by "does the - sqlite db file exist". A team/agent renamed while a non-sqlite driver is - active gets its live registry entry renamed correctly, but the driver's - historical message log is not — no contract function exists yet for - "rewrite a name across history" (`storage_send`/`storage_history`/etc. all - operate on messages, not identities). `api.sh`'s `get teams messages` - read path has the same coupling for a different reason: it needs - `--before-id` cursor pagination `storage_history` does not expose, so it - runs its own event-log ∪ legacy-table query rather than the facade function - — correct today (it still reads driver-shaped tables, not different ones), - but it too would need a driver-contract addition (a paginated history op) to - stop assuming sqlite's schema shape under a non-sqlite driver. +- Positive: one ABI serves any backend with no core changes; the opaque cursor + and the use-case contract make a new backend a self-contained driver; the + default install is unchanged; it aligns with ADR 0002's registry and opt-in. +- Negative: core code that assumes one backend's own id column and read-marking + shape must move behind the contract before any second driver works. Sourced + drivers keep ADR 0002's trust concern, mitigated by opt-in and the `storage_*` + prefix discipline. - Neutral: the subcommand+pipe protocol and multi-host coordination are explicitly *deferred, not rejected* — each can land under a later ADR. @@ -92,7 +78,5 @@ sqlite). 1.1.2 = `redis` (message store). - Builds on [ADR 0001](0001-storage-driver-pluginization.md) and [ADR 0002](0002-driver-discovery-and-plugin-opt-in.md). -- Spec (where the `storage_*` signatures live, not this ADR): +- The `storage_*` signatures live in the spec, not this ADR: [`docs/spec/driver-interface.md`](../spec/driver-interface.md). -- Implementation: #51 (epic), #203 (contract), #204 (facade + sqlite), - #205 (event-log), #206 (call-site migration), #207 (jsonl+duckdb), #208 (redis). diff --git a/docs/adr/0005-remote-sync-contract.md b/docs/adr/0005-remote-sync-contract.md index 67e90c2f..ad2efa86 100644 --- a/docs/adr/0005-remote-sync-contract.md +++ b/docs/adr/0005-remote-sync-contract.md @@ -75,9 +75,9 @@ per-agent wake decisions stay local. The synchronization server never chooses team keys and never receives plaintext private team or recovery keys. Sealing and opening happen client-side. This is a content-confidentiality boundary, not an anonymity claim: team identity, wire -ID, sequence, server receipt time, envelope version, cipher, the scheme-defined -key identifier (`key_id`), digest, size, timing, and traffic frequency remain -visible as defined by the protocol. +ID, sequence, server receipt time, envelope version, cipher, whichever key the +scheme names for that envelope, digest, size, timing, and traffic frequency +remain visible as defined by the protocol. ### Progress layers remain independent diff --git a/docs/spec/stage-1-remote-sync.md b/docs/spec/stage-1-remote-sync.md index 5c01c6c2..8a8a2a2b 100644 --- a/docs/spec/stage-1-remote-sync.md +++ b/docs/spec/stage-1-remote-sync.md @@ -34,13 +34,15 @@ storage_sync_apply_pull [] ``` -Two bundled drivers implement it. SQLite advertises -`stage1-sync,stage1-resync,stage2-read-state`; JSONL advertises `stage1-sync` -alone, so the recovery operation below is genuinely optional rather than -optional in name — a client asking for it against JSONL is refused for want of -the advertised capability, not left to discover a missing command. Drivers that -advertise no extension remain valid local-only drivers. Core must fail clearly -rather than emulate these durability operations outside an unsupported driver. +More than one driver implements it, and they need not implement the same +subset: the four operations above sit behind `stage1-sync`, while the recovery +operation defined at the end of this document sits behind `stage1-resync`. A +driver advertises each capability separately, so the recovery operation is +genuinely optional rather than optional in name — a client that asks for it +where it is not advertised is refused for want of the capability, not left to +discover a missing command. Drivers that advertise no extension remain valid +local-only drivers. Core must fail clearly rather than emulate these durability +operations outside an unsupported driver. The binding is keyed by immutable `server_instance_id`, the server's stable team/stream ID, and protocol version. Endpoint URL is deliberately absent: the @@ -93,8 +95,7 @@ for an existing reservation. It contains the engine's validated envelope selection and capability limits, but no credentials. The ABI is cipher-neutral: the driver creates the canonical envelope selected by the binding configuration. `none` is the default profile. -The optional `age-v1` profile defined in -[`age-v1` profile](age-v1-profile.md) performs its +The optional [`age-v1` profile](age-v1-profile.md) performs its encrypt-once operation at this same boundary. Prepare receives only the public recipient manifest; age identity files remain in the HTTP engine's open path and never cross the storage-driver boundary. From 9ea471c2926a3c56e86c6c030fddf0df7f10b422 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 17 Aug 2026 21:47:49 -0700 Subject: [PATCH 6/6] docs(spec): name the two sync contracts, and drop the stage numbering Stage 1, Stage 2 and Stage 3 were a delivery order, not a distinction a reader needs. They ran through the normative specs and one of the filenames, so a future reader had to learn a three-step plan - one step of which was never built - before learning what the documents cover. The split is messages versus read state, so the documents say that: stage-1-remote-sync.md becomes message-synchronization.md, pairing with the read-state-synchronization.md beside it, and both titles now name their subject. Stage 3 was server-sent events and wake delivery; that section now says what is out of scope without implying a numbered step someone is waiting on. The capability strings stage1-sync, stage1-resync and stage2-read-state are NOT renamed. Drivers already advertise them and an external driver may too, so changing them breaks the driver ABI rather than a document. They are explained once, where the capabilities are defined, as fixed names whose numbers carry no meaning beyond telling the three apart. --- docs/adr/0005-remote-sync-contract.md | 2 +- .../adr/0006-composite-read-state-frontier.md | 2 +- .../0007-stable-member-and-roster-identity.md | 6 ++-- docs/design.ja.md | 2 +- docs/design.md | 2 +- docs/design/remote-sync.md | 2 +- docs/spec/age-v1-profile.md | 6 ++-- docs/spec/driver-interface.md | 8 +++--- ...ote-sync.md => message-synchronization.md} | 28 +++++++++++-------- docs/spec/read-state-synchronization.md | 24 ++++++++-------- docs/spec/retention-gap-resynchronization.md | 14 +++++----- scripts/drivers/storage/sqlite-sync.sh | 2 +- scripts/remote-sync.sh | 2 +- server/spec/v1.md | 4 +-- 14 files changed, 55 insertions(+), 49 deletions(-) rename docs/spec/{stage-1-remote-sync.md => message-synchronization.md} (91%) diff --git a/docs/adr/0005-remote-sync-contract.md b/docs/adr/0005-remote-sync-contract.md index ad2efa86..ed86a0db 100644 --- a/docs/adr/0005-remote-sync-contract.md +++ b/docs/adr/0005-remote-sync-contract.md @@ -156,7 +156,7 @@ preserved identity bytes. ## Normative specifications - [HTTP API v1](../../server/spec/v1.md) -- [Stage-1 local-first remote synchronization](../spec/stage-1-remote-sync.md) +- [Local-first message synchronization](../spec/message-synchronization.md) - [Cipher-independent opaque-envelope server schema](../spec/server-opaque-envelope.md) - [Retention-gap resynchronization](../spec/retention-gap-resynchronization.md) - [Storage driver interface](../spec/driver-interface.md) diff --git a/docs/adr/0006-composite-read-state-frontier.md b/docs/adr/0006-composite-read-state-frontier.md index 1765d02d..4cb45203 100644 --- a/docs/adr/0006-composite-read-state-frontier.md +++ b/docs/adr/0006-composite-read-state-frontier.md @@ -128,7 +128,7 @@ fail-closed overflow are architecture requirements. ## Normative specifications -- [Stage-2 read-state synchronization](../spec/read-state-synchronization.md) +- [Read-state synchronization](../spec/read-state-synchronization.md) - [HTTP API v1](../../server/spec/v1.md) - [Storage driver interface](../spec/driver-interface.md) - [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md) diff --git a/docs/adr/0007-stable-member-and-roster-identity.md b/docs/adr/0007-stable-member-and-roster-identity.md index 84f7b302..2f7be83d 100644 --- a/docs/adr/0007-stable-member-and-roster-identity.md +++ b/docs/adr/0007-stable-member-and-roster-identity.md @@ -71,8 +71,8 @@ messages or free the old identity for silent reuse. When concurrent creators propose the same normalized new name, the first accepted `member_id` is canonical. Another ID is not merged by name. A local member awaiting remote acceptance remains `pending_remote_acceptance` and -cannot act, send, create read facts, or participate in Stage 1 or Stage 2 until -the server accepts that identity. +cannot act, send, create read facts, or participate in message or read-state +synchronization until the server accepts that identity. ### Roster mutations converge by dedupe then revision order @@ -174,6 +174,6 @@ reconciliation protocol; matching display names are insufficient. - [Remote sync design](../design/remote-sync.md) - [HTTP API v1](../../server/spec/v1.md) -- [Stage-2 read-state synchronization](../spec/read-state-synchronization.md) +- [Read-state synchronization](../spec/read-state-synchronization.md) - [ADR 0005: Remote synchronization contract](0005-remote-sync-contract.md) - [ADR 0006: Composite read-state frontier](0006-composite-read-state-frontier.md) diff --git a/docs/design.ja.md b/docs/design.ja.md index 80c08be7..32ab604a 100644 --- a/docs/design.ja.md +++ b/docs/design.ja.md @@ -198,7 +198,7 @@ Claude Code コマンドは別途 `~/.claude/commands/.md` にインスト プラットフォームでも、いかなる状況でも、自身の使用可否を確認する目的 で実行されることは無い — 「試しに実行してみて様子を見る」という類の 解決策こそ、このチェックが避けようとしているものそのものである。 -- **remote sync data plane(Stage-1のポーリング同期クライアント)** — +- **remote sync data plane(ポーリング方式のメッセージ同期クライアント)** — coreに加えて `node`(`remote-sync.sh`が`AGMSG_SYNC_NODE_BIN`/ `agmsg_resolve_node`経由で`internal/remote-sync.mjs`とその周辺の`.mjs` ヘルパー群をexecする)。上記control planeのpython3必要性とは別の、 diff --git a/docs/design.md b/docs/design.md index 977eda23..f8c4d4f9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -223,7 +223,7 @@ either). probe for its own usability, on any platform, under any circumstance; "try running it and see what happens" is exactly the category of fix this exists to avoid. -- **remote sync data plane (the Stage-1 polling sync client)** — core, +- **remote sync data plane (the polling message-sync client)** — core, plus `node` (`remote-sync.sh` execs `internal/remote-sync.mjs` and its companion `.mjs` helpers via `AGMSG_SYNC_NODE_BIN`/`agmsg_resolve_node`). A second, independent reason a remote-connected team needs Node, diff --git a/docs/design/remote-sync.md b/docs/design/remote-sync.md index f6501791..636880bc 100644 --- a/docs/design/remote-sync.md +++ b/docs/design/remote-sync.md @@ -8,7 +8,7 @@ hosted service, so it does not belong in this repository. Two further documents history: `local-first-onboarding.md`, whose onboarding this document replaces, and `authentication-result-handoff.md`, a proposal for a seam this design does not have — see "No authentication" below for what it removes. Their reasoning -is in the git history. Sync itself — Stage 1, read state, +is in the git history. Sync itself — messages, read state, retention gaps, the envelope format — is unchanged and specified elsewhere. ## What a remote is diff --git a/docs/spec/age-v1-profile.md b/docs/spec/age-v1-profile.md index c389d329..da64323e 100644 --- a/docs/spec/age-v1-profile.md +++ b/docs/spec/age-v1-profile.md @@ -7,7 +7,7 @@ This document pins the first encrypted envelope profile for the agmsg remote sync protocol. It extends the opaque envelope in [`server/spec/v1.md`](../../server/spec/v1.md) without changing the HTTP message -schema or the Stage-1 storage-driver durability boundary. +schema or the storage-driver durability boundary. `age-v1` is a standard binary [age v1 file][age-format], encrypted to native X25519 age recipients. It deliberately does not define another AEAD layer or a @@ -291,7 +291,7 @@ atomically; a durable wire-only or `sealing` state is forbidden. Concurrent sealers for one local message may do redundant work, but only the transaction winner becomes visible and all callers subsequently emit that winner. -The Stage-1 H1 rule is absolute: every retry, reconciliation attempt, crash +The H1 rule is absolute: every retry, reconciliation attempt, crash recovery, export, and compaction replay for that wire ID MUST reuse the exact `v`, `cipher`, `key_id`, and `blob`. A client MUST NOT re-encrypt or re-encode the same published wire ID, even to the same recipients. A regression test MUST @@ -320,7 +320,7 @@ context byte strings. Comparing only hashes, individual fields, or a prefix is insufficient. Implementations MUST NOT project any message field before this comparison succeeds. -Failures map to the Stage-1 durable quarantine layer as follows: +Failures map to the durable quarantine layer as follows: | Condition | Durable state | |---|---| diff --git a/docs/spec/driver-interface.md b/docs/spec/driver-interface.md index 648b089e..fb74e074 100644 --- a/docs/spec/driver-interface.md +++ b/docs/spec/driver-interface.md @@ -301,7 +301,7 @@ its fast band, so its behaviour is **contractual**, not best-effort. A conformin Compaction must never touch `message_sent` records; it operates only on the redundant read-state markers layered over them. -### 2.8 Optional Stage-1 remote synchronization extension +### 2.8 Optional message-synchronization extension A driver that can make remote reconciliation atomic with its local message log may advertise `capabilities=stage1-sync` from `storage_describe` and implement: @@ -332,12 +332,12 @@ prefix. Apply-pull atomically quarantines unchanged envelopes, reconciles mapped echoes or imports unmapped wire IDs once, and advances the transport cursor only after durable local outcomes. Transport, decrypt/import, and read progress are independent. Reprocess emits blocking quarantine records for explicit policy/key -reevaluation without rewinding transport. It uses the Stage-1 specification's stable +reevaluation without rewinding transport. It uses that specification's stable `(server_seq,wire_id)` keyset page and mandatory `sync_reprocess_page` trailer, so one explicit engine invocation reaches every candidate without an early permanent failure starving later records. The complete framing, record schemas, crash boundaries, and future reserved operation names are defined by -[Stage-1 synchronization specification](stage-1-remote-sync.md). +[message-synchronization specification](message-synchronization.md). A driver may additionally advertise `stage1-resync` and implement the explicit operator recovery contract from the @@ -357,7 +357,7 @@ independent state layers. The retention-gap specification pins their exact stric audit, and result objects, including canonical sequence arithmetic and duplicate/unknown-field rejection. -The independent Stage-2 extension from the +The independent read-state extension from the [read-state synchronization specification](read-state-synchronization.md) is advertised as `capabilities=stage1-sync,stage2-read-state` and adds: diff --git a/docs/spec/stage-1-remote-sync.md b/docs/spec/message-synchronization.md similarity index 91% rename from docs/spec/stage-1-remote-sync.md rename to docs/spec/message-synchronization.md index 8a8a2a2b..36450744 100644 --- a/docs/spec/stage-1-remote-sync.md +++ b/docs/spec/message-synchronization.md @@ -1,4 +1,4 @@ -# Stage-1 local-first remote synchronization specification +# Local-first message synchronization specification **Status:** current **Last updated:** 2026-07-25 @@ -10,8 +10,10 @@ The irreversible architectural decisions behind this contract are recorded in The storage-axis ABI in ADR 0003 covers local message storage and delivery. It does not define the crash boundaries needed to replicate a local-first store to -the versioned HTTP API in `server/spec/v1.md`. Stage 1 adds polling push/pull for -dogfood while keeping `storage_send` local and independent of network health. +the versioned HTTP API in `server/spec/v1.md`. This specification adds polling +push/pull of messages while keeping `storage_send` local and independent of +network health. Synchronizing read state is a separate contract, specified in +[read-state synchronization](read-state-synchronization.md). The HTTP engine and the storage driver have different responsibilities. The engine owns transport, authentication, capability and binding validation, @@ -24,8 +26,8 @@ never advance a cursor ahead of durable local state. ### Optional synchronization extension -A storage driver may advertise the Stage-1 extension and implement these four -operations in addition to the ADR 0003 ABI: +A storage driver may advertise the message-synchronization extension and +implement these four operations in addition to the ADR 0003 ABI: ```text storage_sync_prepare_push @@ -35,9 +37,13 @@ storage_sync_reprocess min_available_seq`. No event is fabricated for the unavailable range. -Drivers without `stage1-resync` remain valid Stage-1 drivers. The engine checks +Drivers without `stage1-resync` still synchronize messages. The engine checks the advertised capability before the operator command and fails without a network or local mutation when it is absent. @@ -201,8 +201,8 @@ live-row count without logging envelopes or credentials. ## References -- [Stage-1 remote synchronization](stage-1-remote-sync.md) -- [Stage-2 read-state synchronization](read-state-synchronization.md) +- [Message synchronization](message-synchronization.md) +- [Read-state synchronization](read-state-synchronization.md) - [ADR 0005: Remote synchronization contract](../adr/0005-remote-sync-contract.md) - [ADR 0006: Composite read-state frontier](../adr/0006-composite-read-state-frontier.md) - [HTTP API v1](../../server/spec/v1.md) diff --git a/scripts/drivers/storage/sqlite-sync.sh b/scripts/drivers/storage/sqlite-sync.sh index 39349889..d9ca02ea 100644 --- a/scripts/drivers/storage/sqlite-sync.sh +++ b/scripts/drivers/storage/sqlite-sync.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Optional Stage-1 remote synchronization extension for the SQLite driver. -# See docs/spec/stage-1-remote-sync.md. All bulk input/output is JSONL. +# See docs/spec/message-synchronization.md. All bulk input/output is JSONL. _sqlite_sync_uuid4() { local h n variant diff --git a/scripts/remote-sync.sh b/scripts/remote-sync.sh index 79fda219..484d77a5 100755 --- a/scripts/remote-sync.sh +++ b/scripts/remote-sync.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Stage-1 polling synchronization client (dogfood; docs/spec/stage-1-remote-sync.md). +# Polling message-synchronization client (docs/spec/message-synchronization.md). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/server/spec/v1.md b/server/spec/v1.md index f76b8094..1bea4f33 100644 --- a/server/spec/v1.md +++ b/server/spec/v1.md @@ -10,7 +10,7 @@ The reasoning behind the model — why remote synchronization is local-first, an why reaching the server is the permission — is [the remote-sync design](../../docs/design/remote-sync.md). The driver-side durability contract those endpoints are consumed through is -[the Stage-1 synchronization specification](../../docs/spec/stage-1-remote-sync.md). +[the message-synchronization specification](../../docs/spec/message-synchronization.md). Neither restates the endpoint shapes below; this document is the only record of them. @@ -757,7 +757,7 @@ projected onto a new identity. ## `POST /v1/read-state/sync` -Stage-2 read state is monotonic and member-scoped. The request max-merges one +Read state is monotonic and member-scoped. The request max-merges one or more remote frontiers and set-unions exact out-of-order wire reads, while also selecting one bounded response page: