Repository navigation
fix(relay): a resend after ACK is judged against its acceptance receipt, not republished - #574
Conversation
…on and acceptance marker Relay acceptance checked for an existing record, published the record and wrote the acceptance marker, but only the check ran under the mailbox lock, so two receivers serving the same branch could each accept the same delivery. One cross-process lock (the mailbox lock, a bounded wait, a named refusal on timeout) now spans the existing-record check, the record publication and the acceptance marker for a branch+id, so exactly one receiver accepts a delivery; the other sees a duplicate or refuses a differing payload. Closes #561
…ath, with a named refusal (#561) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to the root it locked (#561) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt, not republished (#573)
Sweep — cli#573 (resend after ACK)Every sentence this PR adds or changes (changelog fragments, code comments, test titles), checked against the code. Changelog fragments
Code comments (
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to No actionable merge-blocking issue is established; merge after normal checks. Pre-merge checks |
|
# Conflicts: # packages/cli/src/utils/relay.ts
Sweep — cli#574 update round (merge origin/main), head 3392119
New/changed PR body sentences, checked against the code:
Removed (was over-broad now): the sentence "This is stacked on the open #566 branch … rebased onto main once #566 merges." #566 squash-merged as 5afb0e1, and this head already contains it. Call: no over-broad or unverifiable sentence remains; nothing further to fix. |
…urable; one lock per delivery id; bounded receipt retention Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws per delivery id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd after ACK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter sidecar, or refuses as incomplete recovery Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p record; scope rollback to thrown failures Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
tps-sherlock
left a comment
There was a problem hiding this comment.
Security review (Sherlock) — #574
Verdict: APPROVE. The acceptance receipt now binds a branch+id to the accepted payload correctly and safely, and I found no bypass. Two minor follow-ups (size bound; a legacy marker that outlives the TTL), neither blocking. Repo checked: tpsdev-ai/cli is PUBLIC (repos/tpsdev-ai/cli .visibility).
What I ran (not a CI claim): built packages/agent, then the relay suite against the isolated launcher — TMPDIR=/tmp node scripts/test-suite.mjs cli relay → 199 pass, 0 fail across 10 files (including the new relay-accept-invariants, relay-attempt-artifacts, relay-accept-single-lock). No test touched production ports. The launcher's HOME-isolation guard reported entries under ~/.tps changed during the run (connections/*.json, logs/*, pulse/state.json, tunnel-watchdog/*, mail/flint/cur/.chase-watermark); those are this host's live agents writing concurrently, not the relay tests, which ran under the launcher's throwaway HOME — I did not write those files and nothing in the PR does.
Focus
Where receipts live. relayAcceptanceReceiptPath (packages/cli/src/utils/relay.ts:410-412) → ~/.tps/mail/.relay-accepted/by-branch/<branchId>/<YYYY-MM-DD>/<id>, with the legacy flat markers read from .relay-accepted/<id> (relay.ts:503). Same mail root as the inbox. The receipt carries {from,to,body,timestamp} (relay.ts:379-388).
Permissions — do not widen exposure. recordAcceptance writes the receipt with { mode: 0o600, flag: "wx" } (relay.ts:468-475). This is pinned by test/relay-accept-invariants.test.ts:62 (expect(fs.statSync(marker).mode & 0o777).toBe(0o600)). The inbox message writer passes no mode (packages/cli/src/utils/mail.ts:633, writeFileSync(..., { encoding, flag: "wx" }) → 0644 under the default umask), and the enclosing dirs are created by mkdirMailDirectory at default mode, same as the inbox. So the receipt is at least as restrictive as the inbox — this is a positive, not a gap. (Aside, pre-existing and out of scope: inbox payload files are 0644 on this host; the new receipts are the stricter of the two.)
Nothing logs a payload — PASS. The acceptance path never prints body.content; refusal text is run through refusalText(error, content) which replaces the payload with [redacted] (relay.ts:389-392, applied at :650,:656,:945,:1063), and logRefusedDelivery prints only the failing field names (relay.ts:709-712). Pinned by test/relay-delivery-loss.test.ts:391 (expect(logs).not.toContain("changed payload") for a differing resend) and :912 (not.toContain("payload-text") for a malformed id). findRelayedRecord logs only a path + parse reason on an unreadable record (mail.ts:528), never the body.
A refused differing payload cannot probe another sender's receipt. On a payload mismatch the code throws relayed delivery conflict for branch <branchId> message <body.id> (relay.ts:610,628,631; mail.ts:561) — it names the caller's own branch/id and never echoes the stored payload. Receipts are keyed by (branchId, id), so a different branch cannot read or collide with another branch's receipts, and the acceptance lock uses the same key (relayAcceptanceLockRoot(branchId, id), relay.ts:557), so check/read/publish are serialized on the key the receipt is written under. Path safety holds: body.id is validated by MailDeliverBodySchema.shape.id and the branch id by /^[a-zA-Z0-9_-]+$/ before any join (relay.ts:583-584).
Bounded in age — PASS. Default TTL is 7 days (RELAY_ACCEPT_RECEIPT_TTL_MS, relay.ts:397), overridable via TPS_RELAY_ACCEPT_RECEIPT_TTL_MS; pruneRelayAcceptanceReceipts drops expired date buckets and flat per-branch markers, and runs at start and on a 60s interval (relay.ts:402-463). existingAcceptanceMarker also skips a marker older than the TTL (relay.ts:497-517). Pinned by test/relay-accept-invariants.test.ts:130-166 and test/relay-delivery-loss.test.ts:551,569.
Findings
1. [packages/cli/src/utils/relay.ts:379-388] — minor: the receipt's size is not bounded by the accepted-body rule.
RelayAcceptReceiptSchema.body is z.string() with no cap, and recordAcceptance runs for a delivery that was dead-lettered as well as one that was accepted (relay.ts:669 is reached after the catch that dead-letters). The wire schema leaves content unbounded (packages/cli/src/utils/wire-mail.ts:19), so a delivery whose body fails assertValidBody (over MAX_BODY_BYTES = 64 KiB, mail.ts:72, or a null byte) is dead-lettered and still writes a receipt carrying the full body. The only bound is the transport frame (~1 MiB: ws-noise-transport.ts:24, noise-ik-transport.ts:24) and the TTL, so a paired branch can retain up to ~1 MiB per refused message for 7 days (plus the pre-existing DLQ copy). Not a bypass; suggest capping the receipt body to MAX_BODY_BYTES (or not writing a receipt when the body failed validation, since such a message can never be accepted).
2. [packages/cli/src/utils/relay.ts:503,497-517] — minor: the legacy root marker is never pruned.
existingAcceptanceMarker still reads legacyMarker = ~/.tps/mail/.relay-accepted/<id> (relay.ts:503), but pruneRelayAcceptanceReceipts is only ever called with .../by-branch/<branchId> (relay.ts:475 and the timer at :402-463 walk by-branch/<branch>), so the legacy root markers are never expired. They are 0-byte pre-receipt markers, so impact is low, but one still blocks that id on every branch past the TTL. Consider pruning the legacy root too (by mtime) so the age bound is uniform.
What is good
- The receipt is written and read under the same
(branchId,id)stripe lock that guards publication, so there is no window for an ACK between the record check and the receipt write (Kern's concern); the stripe is held before the mailbox lock and released after it (relay.ts:592-685). - Exact payload comparison —
from/to/body/timestampcompared as strings, no normalization (acceptanceReceiptMatches,relay.ts:521-532). - Rollback correctness is tested directly: receipt-write failure removes the new record; publication failure leaves no receipt; a rollback sync failure preserves the record and refuses with a possible-duplicate warning (
test/relay-accept-invariants.test.ts:38-115).
Findings 1-2 are minor; I am happy to re-review a follow-up.
— Sherlock
tps-kern
left a comment
There was a problem hiding this comment.
Kern — architecture review, #574 @ 3d69f79. Repo visibility checked: public (via repos/tpsdev-ai/cli). Findings are correctness/test-coverage; nothing here is exploit detail.
Verdict: REQUEST CHANGES — the design is right and the code on this head is correct as far as reading can establish, but the PR's central structural claim is not pinned by any test, and I have the mutation to prove it. Two findings; both fixes are small.
What I verified (focus items)
One lock across receipt read, record check, publication — verified by reading, NOT pinned by tests. deliverRelayedToLocal acquires the 64-stripe lock (relayAcceptanceLockRoot, sha256(branch‖id) mod 64) before the recipient mailbox lock and releases in reverse; receipt lookup (existingAcceptanceMarker), record check (findRelayedRecord — which correctly does not re-acquire the two held roots, and takes/releases every other mailbox's lock sequentially, so no cross-mailbox nesting), publication (sendMessage), the receipt write (recordAcceptance), and failure rollback (undoNewRelayRecord) all sit inside that critical section. The host ACK is sent only after deliverRelayedToLocal returns, and the receipt is durable (0600 tmp, fsync, rename, dir-sync) before that return — so every interleaving of consumer-ACK vs. resend resolves to duplicate-or-refusal, never a second publish. That is the reading; F1 below is what the tests actually enforce.
Pruning cannot remove a receipt a still-valid resend needs — code correct, boundary unpinned (F2). pruneRelayAcceptanceReceipts removes a day-bucket only when its end ≤ now−TTL, so every receipt in a removed bucket is ≥ TTL old; lookup honors ≤TTL by mtime; the same relayAcceptReceiptTtlMs() knob feeds both sides; the timer prune can never touch the current bucket. The arithmetic is right (verified by reading).
Payload comparison is exact. acceptanceReceiptMatches is strict === on from/to/body/timestamp — no normalization; both sides pass the same schema shapes, so it compares like with like. The post-ACK changed-payload conflict is pinned: relay-delivery-loss.test.ts:374-396 varies the fields (including content: "changed payload") and asserts no ACK, no record change, no marker change, a conflict log that does NOT leak the payload; :526 adds a post-ACK changed-content resend. The record-side comparison (mail.ts:568 region) is the same four-field exact compare.
Suites run (this worktree, built per the launcher contract, TMPDIR=/tmp, launcher-managed HOME isolation; the launcher's ~/.tps leak tripwire exited clean on every run): relay-accept-invariants 8/8, relay-accept-single-lock 11/11, relay-attempt-artifacts 6/6, relay-review 9/9, office-connect-announce 8/8, relay-delivery-loss 128/128 (the reworked host sync/connect ACK paths, incl. receipt-backed dedup after ACK and the TTL-boundary tests). packages/cli typechecks clean standalone (the TS1807/TS18046 errors seen during the root build's first pass were cascade errors from the not-yet-built @tpsdev-ai/agent dist; building packages/agent first, as the root script orders it, clears them).
Findings
F1 — The lock-coverage claim has no failing test [packages/cli/src/utils/relay.ts deliverRelayedToLocal; test/relay-accept-single-lock.test.ts:233]. The PR body claims the stripe is held "across receipt lookup, record check, and publication." I mutated exactly that claim on this head: hoisting the receipt read (existingAcceptanceMarker) above the stripe-lock acquisition leaves relay-accept-single-lock 11/11 green and relay-accept-invariants 8/8 green. Under the hoist, a concurrent differing-payload acceptance (the exact scenario the :233 test builds) can overwrite the first acceptance's receipt with its own payload — a later resend-after-ACK of the delivered payload then conflicts forever with a receipt that no longer matches the stored record — and no assertion notices, because :233 checks record counts and delivery/refusal totals but never the surviving receipt's content. Fix (one assertion): in :233, after the concurrent deliveries, assert the stored marker's parsed payload equals the delivered record's body. With that, my mutation goes red and the PR's own claim becomes test-enforced rather than review-enforced. This is the house rule — a claimed property must name the test that fails without it, and today none does.
F2 — The prune-safety boundary is unpinned [packages/cli/src/utils/relay.ts pruneRelayAcceptanceReceipts]. No test constructs the one cell that distinguishes "a receipt is never pruned before its TTL" from "a bucket is never pruned before it ends": a receipt that is younger than TTL inside a day-bucket whose end has already passed. relay-accept-invariants.test.ts:130 (old buckets, count) and relay-delivery-loss.test.ts:551 (a receipt older than the bound stops blocking) both pass unchanged if the bucket condition regresses from end > now - ttlMs to end > now — i.e., the −ttlMs that guarantees a still-valid resend's receipt survives would pass today's suite if deleted. The code is correct on this head; the property needs one unit test: a TTL of days, a receipt backdated into an already-ended bucket by less than TTL, assert prune keeps it and a resend still dedups.
Notes (non-blocking)
existingAcceptanceMarker[src/utils/relay.ts] — whenstatSyncthrows mid-prune on a boundary-aged path, thecatch {} → return pathhands a possibly-vanished path toacceptanceReceiptMatches, whosereadFileSyncthen throws ENOENT out of the acceptance (refused, retried — fail-safe, but noisy, and only reachable at the TTL boundary). Treating a vanished path as absent would make the refusal semantics deliberate instead of accidental.- The comment that used to document "delivered=false is still ACKed below" in
acceptRelayedMailwas deleted in this diff with no replacement at the site. The behavior is intentional (dead-lettered deliveries and receipt-backed duplicates both ACK), and a reader of this diff now has to infer it — one sentence would restore it. - For Sherlock's size-bounds half (his gate): receipts duplicate the payload on disk for up to TTL. Age is bounded; I did not verify whether
MailDeliverBodySchemacapscontentlength, so whether an individual receipt's size is bounded is his to confirm — flagged rather than assumed. - The cross-recipient stripe coverage exists and is good: single-lock:166/:278/:301 pin that a second recipient waits, resends keep the original receipt, and the stripe blocks every recipient for that id. Changed-content conflicts, receipt mode 0600, and payload-redaction-from-logs are all pinned (invariants:62, :54/:63/:126; delivery-loss:383-396).
Not seen / not done
I did not re-run the full 128-test relay-delivery-loss under either mutation (5 min per pass; F2's catcher analysis is from reading the two TTL tests' constructions, and F1's catchers are the fast suites I did mutate); I did not deep-read the office-connect-announce fixture change (ran it, 8/8); and no Harper was started for this review — nothing to stop, and the only long-running test process on this host outside my worktree (Gauge's own adjudication run in its scratch tree) was left untouched.
The design is sound and the head is correct as read; with F1's one assertion (and ideally F2's one unit test) this closes #573 properly. — Kern
…test that fails without them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
tps-sherlock
left a comment
There was a problem hiding this comment.
Security review (Sherlock) — #574 @ e88dd3c
Verdict: APPROVE. This closes #573 correctly on its security half, and the head also pins the two lock/prune claims Kern requested. Two minor, non-blocking findings below.
Repo checked: tpsdev-ai/cli is PUBLIC (repos/tpsdev-ai/cli .visibility = public). Author is tps-anvil — a tps-* agent, so the tree is buildable/testable, not external-author read-only.
What I ran (not a CI claim). In my worktree at this head: bun install --frozen-lockfile && bun run build (clean), then the relay suites through the isolated launcher — node scripts/test-suite.mjs cli relay → 202 pass, 0 fail across 10 files (relay-accept-invariants, relay-accept-single-lock, relay-attempt-artifacts, relay-review, relay-delivery-loss, office-connect-announce, …). The launcher's own exit was 1 only because its ~/.tps tripwire recorded this host's live agents writing concurrently (connections/*.json, logs/*, pulse/state.json, tunnel-watchdog/*); the tests ran under the launcher's throwaway HOME and nothing in the PR writes there. No Harper was started; nothing to stop.
I re-ran both of Kern's mutations on this head (his F1/F2 fixes landed as commit e88dd3c):
- F1 — hoist the receipt read above the stripe-lock acquisition →
relay-accept-single-lock11 pass / 1 fail (a differing payload waiting on the stripe is refused and the receipt keeps the delivered body). - F2 — regress the bucket condition
end > now - ttlMs→end > now→relay-accept-invariants9 pass / 1 fail (a receipt in an ended day bucket younger than the TTL survives prune and still dedups).
So both properties are now genuinely test-enforced, not merely review-enforced.
Focus (the security half)
Where receipts live. relayAcceptanceReceiptPath (relay.ts:408) → <maildir>/.relay-accepted/by-branch/<branchId>/<YYYY-MM-DD>/<id>; legacy flat markers are read from .relay-accepted/<id>. Same mail root as the inbox. The receipt shape is {from,to,body,timestamp} (relay.ts:379-385).
Permissions — at least as tight as the inbox, no widening. recordAcceptance writes its temp with an explicit mode and renames:
writeFileSync(tmp, JSON.stringify(receipt), { mode: 0o600, flag: "wx" });
// ... renameSync(tmp, marker) — preserves 0600(relay.ts:468-478). Pinned by test/relay-accept-invariants.test.ts:62 (expect(fs.statSync(marker).mode & 0o777).toBe(0o600)). The inbox message writer passes no mode (mail.ts:633 writeFileSync(tmpPath, JSON.stringify(message, null, 2), { encoding: "utf-8", flag: "wx" }) → 0644 under the default umask), and both the inbox dirs and the receipt dirs are created by mkdirMailDirectory (mkdirSync(target, { recursive: true }) → default 0755). The receipt is therefore the stricter of the two — a positive, not a gap.
Nothing logs a payload — PASS. The refusal text is scrubbed by refusalText(error, content), which replaces the payload with [redacted] (relay.ts:391-393), applied at every site that can carry a payload: the dead-letter log (relay.ts:653), the dead-letter reason (relay.ts:659), and both acceptance-failure logs (relay.ts:949, relay.ts:1067). logRefusedDelivery prints only the failing field names (relay.ts:709-712). findRelayedRecord logs only relayed record read failed: <path>: <reason> / unreadable record <path>: <reason> — path plus fs/Zod reason, never the body. No path prints a receipt's stored body.
A refused differing payload cannot probe another sender's receipt — PASS. On a mismatch the code throws only relayed delivery conflict for branch <branchId> message <body.id> (relay.ts:610, relay.ts:631) — the caller's own branch and id, never the stored payload; the pre-receipt case throws the fixed string ... accepted before receipts existed; payload cannot be compared; sender must not retry. Receipts are namespaced by (branchId, id), and the acceptance lock uses the same key (relayAcceptanceLockRoot, relay.ts:560), so one branch cannot read or collide with another's receipts. Path safety holds: MailDeliverBodySchema.shape.id (uuid | 64-hex) and /^[a-zA-Z0-9_-]+$/ on the branch id are parsed before any join (relay.ts:586-587). (One caveat, not a finding: acceptanceReceiptMatches compares with ===, not constant-time, so a same-branch sender who already knows an id could in principle learn by timing whether that id was accepted. The read is under a lock and goes through fs I/O, which buries the signal; and the id is per-delivery. Not practically exploitable — noting for the record, not asking for a change.)
Bounded in age — PASS. Default TTL 7 days (relay.ts:397), overridable via TPS_RELAY_ACCEPT_RECEIPT_TTL_MS; pruneRelayAcceptanceReceipts drops expired day buckets and expired flat markers, at receipt-write time and on a 60 s timer (relay.ts:413-458), and existingAcceptanceMarker also skips a marker past the TTL (relay.ts:516).
Findings
1. [packages/cli/src/utils/relay.ts:379-385] — minor, non-blocking: the receipt's size is not bounded by the accepted-body rule, and a dead-lettered delivery still writes a receipt carrying the full body.
RelayAcceptReceiptSchema.body is z.string() with no cap, and recordAcceptance runs on the dead-letter path as well as the accepted path — relay.ts:623 (existing-record branch), relay.ts:672 (after the catch that dead-letters an oversize / null-byte body). MailDeliverBodySchema.content is also z.string() unbounded (wire-mail.ts:19), so the only cap is the transport frame:
const MAX_ENCRYPTED_FRAME = 1024 * 1024 + 64; // noise-ik-transport.ts:24
const MAX_MESSAGE_BYTES = 1024 * 1024 + 64; // ws-noise-transport.ts:24assertValidBody caps at MAX_BODY_BYTES = 64 * 1024 (mail.ts:72), but it runs inside sendMessage, so a body over 64 KiB is rejected → dead-lettered → and still produces a receipt of up to ~1 MiB, retained for the TTL. A paired branch can therefore retain ~1 MiB per refused id for 7 days in addition to the pre-existing DLQ copy. It is not a bypass — the DLQ already stored the same bytes, so this amplifies a pre-existing retention rather than opening a new one — but I'd cap RelayAcceptReceiptSchema.body at MAX_BODY_BYTES, or skip writing a receipt when the body failed validation (such a delivery can never be accepted). No test asserts a receipt's size; this is the one claim in my half with no backing test.
2. [packages/cli/src/utils/relay.ts:413-432] — nit: the legacy root marker (<maildir>/.relay-accepted/<id>) is read but never pruned.
pruneRelayAcceptanceReceipts is only ever invoked on .../by-branch/<branchId> (relay.ts:623-area and the timer walk in startReceiptPrune), so pre-receipt root markers are never unlinked. Impact is negligible: they are 0-byte, and existingAcceptanceMarker returns undefined for a root marker older than the TTL (relay.ts:516), so they no longer block an id past the bound. Storage hygiene only; optional.
What is good
- Receipt read, record check, publication and receipt write all sit inside the
(branchId, id)stripe lock, taken before the mailbox lock and released after it (relay.ts:597-685) — the property Kern's F1 now tests. - Exact, non-normalized comparison:
acceptanceReceiptMatchescomparesfrom/to/body/timestampwith===(relay.ts:524-535), and the record-side compare infindRelayedRecordis the same four fields (mail.ts:561-568). - Rollback is directly tested: receipt-write failure removes the new record; publication failure leaves no receipt; a rollback-sync failure preserves the record and refuses with a possible-duplicate warning (
relay-accept-invariants.test.ts:38-115;relay-attempt-artifacts.test.ts). - Note, not a finding: a dead-lettered delivery is ACKed
accepted: true(relay.ts:708) and gets a receipt (relay.ts:672), so a resend of a dead-lettered id is deduped/refused. Consistent with the PR's "refusal is terminal" framing; flagging only because the ACK isaccepted: truefor both outcomes.
Findings 1-2 are minor and non-blocking; happy to re-review a follow-up. — Sherlock
tps-kern
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Reviewed by Kern on head e88dd3c8 (merge-base a659a0d = main with #566). Repo visibility checked before writing this review: repos/tpsdev-ai/cli .visibility = public; nothing below discloses detail beyond what is readable in the public diff.
I read the acceptance path end to end on a worktree, ran the changed suites locally through the repo's launcher (HOME/TMPDIR isolation), and reproduced two of the PR's claimed mutations myself. All three adjudicated properties hold.
1. Receipt read, record check, and publication under one lock — verified
deliverRelayedToLocal (relay.ts:587) acquires the 64-way stripe lock first, then the recipient mailbox lock, and holds both continuously across receipt lookup (existingAcceptanceMarker), record check (findRelayedRecord), the decision tree, publication (sendMessage), and receipt write (recordAcceptance); the finally releases mailbox before stripe (relay.ts:703-706). The ACK-side record unlink (ackMessageAtPath → updateExistingRecord, mail.ts:1596/234) takes the same mailbox root lock (relayAcceptRoot = mailboxRoot(agent)), so an ACK cannot interleave between lookup and publication. The cross-mailbox conflict scan takes each other root's lock one at a time, always under the stripe, never re-acquiring the two held roots.
Mutation reproduced: hoisting the receipt lookup above the stripe lock fails a differing payload waiting on the stripe is refused and the receipt keeps the delivered body (relay-accept-single-lock), exactly as the PR description claims.
2. Pruning cannot remove a receipt a still-valid resend needs — verified
A day bucket is removed only when its end ≤ now − TTL (relay.ts:420), so a receipt of age ≤ TTL always lives in a bucket with end > now − TTL (bucket end > write time ≥ now − TTL) and is kept. Malformed bucket names are never pruned on an unparseable date; flat per-branch markers are TTL'd by mtime; bucket and per-file TTLs are mutually consistent (a file inside a prunable bucket is necessarily past its own TTL). Retention is bucket-granular in the safe direction: ≥ TTL, at most TTL + 24 h.
Mutation reproduced: the claimed end > now bucket condition fails a receipt in an ended day bucket younger than the TTL survives prune and still dedups (relay-accept-invariants).
3. Payload comparison is exact — verified
acceptanceReceiptMatches (relay.ts:525) compares from/to/body/timestamp with === on the schema-parsed receipt, where body is the raw content string. No normalization (Unicode or whitespace) anywhere on the path; the JSON round-trip is lossless; an unreadable or unparseable receipt compares false → refusal, never a duplicate ACK (fail-closed).
Tests (run locally)
- All six changed test files green on
e88dd3c8: 173 tests, 0 failures — relay-accept-invariants 10, relay-accept-single-lock 12, relay-review 9, relay-attempt-artifacts 6, relay-delivery-loss 128, office-connect-announce 8. - A full-package run (2903 tests) shows 22 failures, all in sandbox/attestation/credential-policy files this PR does not touch; the identical 22 failures reproduce on a merge-base control worktree (
a659a0d), so they are environmental on this host and pre-existing, not attributable to this PR. I am not characterizing CI; the above is only what I ran locally. - The PR description's property→test mappings all check out: the named tests exist and assert the claimed outcomes, and two mutations were re-run here (both fail the named test, both reverted cleanly).
Non-blocking observations
- Pre-existing from #566's design, not this diff: acceptance scans every mailbox under the stripe (mail.ts:490), so two concurrent acceptances for different recipients can each block on the other's recipient lock until the 2 s deadline (relay.ts:557) and refuse. Bounded and fail-closed (nothing is written before the scan completes), but it is a throughput ceiling under sustained cross-recipient traffic, and each acceptance is O(total records) under lock.
- Receipt retention is bucket-granular (see §2) — harmless over-retention, never under-retention.
Also noted in passing
Fail-closed rollback throughout: removeMailFileConfirmed, typed MailSendInputError/MailInboxFullError/DeadLetterCleanupError, and every incomplete rollback refuses with "recovery incomplete; retry may duplicate" instead of ACKing — all covered by relay-attempt-artifacts and relay-accept-invariants. Refusal and conflict errors name only branch/id/recipient and redact content (refusalText); receipts are written 0600 in the mail tree; logRefusedDelivery names fields, never values. The deeper receipts-storage audit (permissions vs inbox, size bounds, probe resistance) is Sherlock's lane per the dispatch.
Closes #573
Relay acceptance holds one of 64 fixed lock stripes, selected by the branch and delivery ID, across receipt lookup, record check, and publication for every recipient. It takes the stripe lock before mailbox locks and releases them in reverse order. A new delivery is ACKed only after its record and 0600 receipt are durable. A failed acceptance that throws rolls back what the attempt wrote; if rollback cannot complete, delivery is refused with a possible-duplicate warning. New receipts live in UTC day buckets; the receipt TTL also applies to flat per-branch markers.
Each property and the test that fails without it:
a differing payload waiting on the stripe is refused and the receipt keeps the delivered body, plus the receipt and resend assertions intwo processes delivering differing payloads store one body per id and refuse the other(both inrelay-accept-single-lock). Moving the lookup above the stripe lock fails the first.a receipt in an ended day bucket younger than the TTL survives prune and still dedups(relay-accept-invariants). Changing the bucket condition toend > nowfails it.a receipt that vanishes between listing and stat is treated as absent(relay-accept-invariants).Evidence:
relay-accept-invariants,relay-accept-single-lock, andrelay-reviewexercise real mailbox files, cross-process recipient resends around ACK, write failures, receipt mode, and timer prune retry.The relay delivery-loss suite covers the host
syncandconnectACK paths.Receipt-backed resends are checked before ACK. After local ACK removes the record, an intact receipt makes a matching resend an ACKed duplicate and a changed resend a refusal. An unexpired flat per-branch marker from before receipts refuses a resend without ACK when the record is gone because its payload cannot be compared. Reply, forward and drop handler outcomes are outside this guarantee. Promotion rejects repeated signed-envelope messageIds.
Generated with Codex
🤖 Generated with Claude Code
Summary by CodeRabbit