Skip to content

NA-0678 (D614): relay invite slots, capability gate, and a durability proof that can fail - #66

Merged
Tebbens4832 merged 1 commit into
mainfrom
na0678-invite-slots
Jul 26, 2026
Merged

NA-0678 (D614): relay invite slots, capability gate, and a durability proof that can fail#66
Tebbens4832 merged 1 commit into
mainfrom
na0678-invite-slots

Conversation

@Tebbens4832

Copy link
Copy Markdown
Member

Goals: G1, G4

Messaging epic Slice 1 — the relay-side invite primitives, built with no client dependency. Executes QSL-DIR-2026-07-26-614 (D614), APPROVED 2026-07-26 (sha256 383dc366…016269, 600 lines) under qsl-protocol lane NA-0678. Records D-0016.

What lands

Three additive routes — POST /v1/invite/{create,redeem,revoke} — plus a slot-scoped admission check on the existing push path, an invites table at SCHEMA_VERSION 2, and five resource controls.

  • The client mints the capability and uploads only SHA-256(cap). There is no mint endpoint, so no relay-side path holds a capability in plaintext before a redeemer presents one — a relay operator cannot silently burn an invite it hosts.
  • Nothing secret rides a URI. All three routes are POSTs carrying invite_id and every secret in the JSON body. invite_id is the mailbox route key, and D-0008/D-0009/D-0010 already retired URI-carried route tokens.
  • Redemption is an atomic compare-and-set — exactly one winner, every loser gets ERR_INVITE_ALREADY_USED — and issues a one-shot handshake ticket. Without a live ticket a push to a slot is refused, so the slot accepts exactly one handshake from the party that actually redeemed, not from anyone who saw the code and lost the race.
  • Tombstones, not deletes. Consumed and revoked slots persist to expiry with blobs cleared, so already-used stays distinguishable from not-found — a deleted slot reports "never existed" when the truth is "someone got here first".
  • The slot cap and the create-rate bucket are both required and are not substitutes. The cap bounds storage and never evicts; the bucket (global, because an invite has no route token until it exists) bounds denial.
  • invite_id, cap, revoke_token and the ticket are stored only as digests and compared with the existing ct_eq_secret (D-0014). No new primitive; Cargo.toml and Cargo.lock diff is EMPTY.

The compatibility guarantee

/v1/push, /v1/pull, /v1/pull/ack are unchanged for every route the invite system did not create — one indexed lookup misses and the pre-existing path runs as before. This is what lets the shipped qsc client, the spine's pinned in-process e2e and the qsl-attachments interop contract keep working while the client slices are still unwritten. tests/qsl_attachments_integration_contract.rs and tests/na0642_backward_compat.rs both pass unchanged and are the named proof.

⚠ Two defects the census found, fixed here

The schema-version guard had been inert since it was written. The marker used INSERT OR IGNORE, a no-op on an existing key, so a forward migration never advanced it — D-0011's fail-closed downgrade guard could not fire after any schema change, and this is the first schema change since D-0011. Now fixed, with a positive and a negative control (tests/na0678_schema_version.rs).

tests/na0642_durability_restart.rs cannot prove the claim it is cited for. It is the project's stated proof that "a 200 means fsynced". SIGKILL destroys a process, not the page cache, so synchronous=FULL and synchronous=OFF are indistinguishable to it — measured: that suite passes 3/3 with the pragma OFF. Its header comment is corrected and the test is kept unchanged for the process-crash durability it genuinely proves. The fsync claim is discharged instead by tests/na0678_invite_durability.rs, which counts real fsync syscalls, asserts the fsync precedes the 200 on the wire, and skips with a stated reason when strace is absent rather than passing silently.

Both arms measured on the invite-create path: shipped config 5 creates → 5 fsyncs with fsync-before-200 ordering observed; a synchronous=OFF control built outside the tree 5 creates → 0 fsyncs. The instrument moves in both directions, so its green carries information.

Also in scope per D614 §4b

DECISIONS.md D-0015 gains a mark-don't-rewrite correction: its text still said public-safety and advisories were advisory-only, which was true at landing and false since the NA-0677 closeout promoted all three contexts. TRACEABILITY.md gains this lane's row only — the ENG-0066 backfill is deliberately not attempted here, so a wire-contract change is not buried under a documentation sweep.

Verified locally

RUST_TEST_THREADS=2 cargo test -qexit 0, 29 suites / 129 tests (⚠ at full parallelism tests::logs_do_not_contain_raw_channel flakes — ENG-0065, pre-existing, unrelated) · cargo clippy --all-targets -- -D warnings clean · cargo fmt --check clean · infra_literal_scan --mode tree clean (76 files, 15,254 lines examined) and --mode diff clean (13 files, 2,258 lines examined).

The spine's qsl-server dev-dependency pin is not bumped here — that is a named step between Slice 1 and Slice 2 (ENG-0041 / NA-0640 discipline).

… proof that can fail

Messaging epic Slice 1 -- the relay-side primitives the client slices will need,
built with no client dependency. Three additive routes, one new table, and a
slot-scoped admission check on the existing push path.

The invite subsystem
--------------------
POST /v1/invite/{create,redeem,revoke}. All three carry invite_id and every
secret in the JSON BODY, never a path or query parameter: invite_id IS the
mailbox route key, and D-0008/D-0009/D-0010 already retired URI-carried route
tokens because they leak through proxy logs, shell history and traces.

The CLIENT mints the redemption capability and uploads only SHA-256(cap). There
is deliberately no mint endpoint, so no relay-side path holds a capability in
plaintext before a redeemer presents one -- a relay operator cannot silently
burn an invite it hosts. invite_id, cap, revoke_token and the handshake ticket
are persisted only as digests and compared with the existing ct_eq_secret
(D-0014); no new primitive, no new dependency.

Redemption is an atomic compare-and-set -- exactly one winner, every loser gets
ERR_INVITE_ALREADY_USED -- and issues a one-shot handshake ticket. A push to an
invite slot without a live ticket is refused, so the slot accepts exactly one
handshake from the party that actually redeemed rather than from anyone who saw
the code and lost the race. A 128-bit revoke_token returned once at create
authorizes revoke; without it an open relay would let any code-holder destroy
any invite.

Consumed and revoked slots are TOMBSTONED until expiry with their blobs cleared,
so invite-already-used stays distinguishable from invite-not-found. A deleted
slot would report "never existed" when the truth is "someone got here first" --
the interception signal the design exists to surface.

The slot cap bounds storage and never evicts when full; a GLOBAL create-rate
bucket bounds denial. Global because an invite has no route token until it
exists, so the per-route push bucket structurally cannot cover it. The two are
not substitutes: the availability of invite-create is a security property.

/v1/push, /v1/pull and /v1/pull/ack are UNCHANGED for every route the invite
system did not create -- one indexed lookup misses and the pre-existing path
runs as before. That is what lets the shipped client, the spine's pinned e2e and
the attachments contract keep working while the client slices are unwritten.

Two defects the census found, fixed here
----------------------------------------
The store's schema-version marker was written with INSERT OR IGNORE, a no-op on
an existing key, so a forward migration never advanced it and D-0011's
fail-closed downgrade guard had been inert since it was written. The migration
now advances it, with a positive and a negative control.

tests/na0642_durability_restart.rs was cited as the proof that "a 200 means
fsynced" and cannot be: SIGKILL destroys a process, not the page cache, so
synchronous=FULL and synchronous=OFF are indistinguishable to it -- that suite
passes 3/3 with the pragma OFF. Its header comment is corrected and the test is
kept unchanged for the process-crash durability it genuinely proves. The fsync
claim is discharged instead by tests/na0678_invite_durability.rs, which counts
real fsync syscalls, asserts the fsync precedes the 200 on the wire, and skips
WITH A STATED REASON when strace is absent rather than passing silently.

Verified locally: 29 suites / 129 tests green at RUST_TEST_THREADS=2; clippy
--all-targets -D warnings clean; fmt clean; infra-literal tree scan clean (76
files, 15254 lines examined); Cargo.toml and Cargo.lock diff EMPTY.
@Tebbens4832
Tebbens4832 merged commit 131d63f into main Jul 26, 2026
3 checks passed
@Tebbens4832
Tebbens4832 deleted the na0678-invite-slots branch July 26, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant