Repository navigation
feat(edgesync): bundle acknowledgment, closing the air-gap loop (#569) - #583
Merged
Merged
Conversation
PR 9d of 4, the last of item 9. On a successful import the hub writes a
signed ack.json INTO the bundle directory, so the drive going back
carries its own receipt and cannot be separated from what it answers. The
spoke verifies it and advances those files from `exported` to `synced`.
That advance is the point. Before it, `synced` was unreachable on an
air-gapped spoke: PruneSynced never pruned and the ledger grew without
bound on the box least able to receive a site visit. Verified live —
exported 4 / synced 0, drive to hub and back, exported 0 / synced 4.
A fourth HMAC family, `sync-ack` (length 8, distinct from sync-file 9,
sync-bundle 11, sync-reconcile 14, so length-prefixing keeps all four
non-interchangeable). The hub signs with the SAME per-spoke secret the
spoke signs with — it is symmetric, so the key that proves authorship
also proves receipt. No new key material.
The spoke RECOMPUTES the path digest rather than trusting the one in the
file. The MAC binds the digest, so a tampered path list carrying a stale
digest would otherwise validate and license marking files synced that no
hub ever received — silent data loss, since the spoke then stops
re-sending them.
Conflicted paths are deliberately NOT acknowledged: a conflict means the
hub holds different content there, so the spoke's copy was never
delivered. Those stay `exported` and are reported for a human.
Cross-PR regression found by tracing 9b against 9d, not by any test:
writing ack.json into the bundle broke BundleReader.Verify. 9b's
rejectUndeclared refuses any file not named in entries.jsonl — added in
response to a review finding, and exactly what caught this — so an
operator auditing a returned drive got "unsigned payload" and a re-import
was refused with 422 instead of 409. ack.json is now exempt from the
manifest digest (it cannot be covered: it is created after the manifest
is signed) but is NOT exempt from its own signature: a replaced ack still
fails ReadAck, and a decoy beside it still fails verification.
Test plan:
- [x] 8 ack tamper cases: path added, path removed, MAC, bundle ID,
import time, another hub, another spoke, namespace escape
- [x] round trip verified to FAIL without ack writing (mutant compiled)
- [x] re-verification exemption verified to FAIL without it
- [x] conflicts not acknowledged; re-applying is a no-op; missing ack is
distinguishable from an invalid one; oversized ack refused
- [x] live two-process round trip on the fixed binary: import 4, drive
re-imports as 409 not 422, ack advances exported 0 / synced 4
- [x] go test -race, go vet, gofmt clean on a cleared test cache
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to review findings on the acknowledgment.
AckResult.Unknown collapsed three cases that mean opposite things:
already-synced (the benign replay), untracked (a restored spoke), and a
terminally failed entry the hub says it HOLDS. Only the third means
something is wrong, and it was invisible — verified: an ack naming one
never-tracked and one failed path reported unknown=2, indistinguishable.
Split into AlreadySynced / Untracked / Discrepancies. A discrepancy logs
at Warn and surfaces a warning on the endpoint; the other two are quiet.
A failed entry is still never resurrected — MarkSynced excludes it — so
this changes reporting, not state.
Also documents why an acknowledged entry that is still `pending` IS
advanced: ReadAck has proven the hub holds that exact path, so `synced`
is factually true however it got there, and re-sending over a contact
window would spend link budget on a file already delivered. Deliberate,
not inherited from MarkSynced's reconcile justification.
The endpoint now accumulates warnings rather than assigning one field,
so a run with both conflicts and discrepancies reports both. Release
notes show imported_at and conflicts, which the handler always emits.
Test plan:
- [x] TestAck_NonAdvancedPathsAreClassified covers all three, and that a
failed entry stays failed
- [x] the replay test now asserts already_synced=2, discrepancies=0
- [x] go test -race, go vet, gofmt clean on a cleared test cache
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On a successful import the hub writes a signed
ack.jsoninto the bundle directory, so the drive going back carries its own receipt and cannot be separated from what it answers. The spoke verifies it and advances those files fromexportedtosynced.That advance is the whole point. Before it,
syncedwas unreachable on an air-gapped spoke:PruneSyncednever pruned and the ledger grew without bound on the box least able to receive a site visit. Verified live:A fourth HMAC family,
sync-ack(length 8 — distinct fromsync-file9,sync-bundle11,sync-reconcile14, so length-prefixing keeps all four non-interchangeable). The hub signs with the same per-spoke secret the spoke signs with: it is symmetric, so the key that proves authorship also proves receipt. No new key material.The spoke recomputes the path digest rather than trusting the one in the file. The MAC binds the digest, so a tampered path list carrying a stale digest would otherwise validate and license marking files synced that no hub ever received — silent data loss, since the spoke then stops re-sending them.
Conflicted paths are not acknowledged. A conflict means the hub holds different content there, so the spoke's copy was never delivered; those stay
exportedand are reported for a human.The cross-PR regression
Found by tracing 9b against 9d, not by any test: writing
ack.jsoninto the bundle brokeBundleReader.Verify. 9b'srejectUndeclaredrefuses any file not named inentries.jsonl— added in response to a review finding, and exactly what caught this. An operator auditing a returned drive got "unsigned payload", and a re-import was refused with422instead of409.ack.jsonis now exempt from the manifest digest (it cannot be covered — it is created after the manifest is signed) but not from its own signature: a replaced ack still failsReadAck, and a decoy beside it still fails verification. The exemption is an exact match, soack.json.bak,ack.json2, case variants, and a directory of that name are all still refused.Review findings, fixed
AckResult.Unknowncollapsed three cases that mean opposite things: already-synced (benign replay), untracked (a restored spoke), and a terminally failed entry the hub says it holds. Only the third means something is wrong, and it was invisible — an ack naming one never-tracked and one failed path reportedunknown=2, indistinguishable. Now split, with a warning on the discrepancy case only. A failed entry is still never resurrected.Test plan
409not422, ack advancesexported 0 / synced 4, replay reportsalready_synced: 3with no discrepancygo test -race,go vet,gofmtclean on a cleared test cacheDocs
Basekick-Labs/docs.basekick.net#23 — stacked on #22.
🤖 Generated with Claude Code