Skip to content

feat(devices): synced device-trust propagation (TRUST-01)#132

Merged
Reederey87 merged 4 commits into
mainfrom
feat/trust-01
Jul 6, 2026
Merged

feat(devices): synced device-trust propagation (TRUST-01)#132
Reederey87 merged 4 commits into
mainfrom
feat/trust-01

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the standing P6-SYNC-01/P6-SYNC-03 residual (spec/07 "revoke is local-only", spec/15:180): devices revoke/lost now emit synced device.revoked/device.lost events, so one device's revoke reaches the whole fleet instead of requiring a manual operator action per device.

Semantics (design anchored on Keybase sigchain-revocation analysis — no trust CRDTs/ordering service for a single-user fleet):

  • Emission: trust flip + event insert in ONE transaction (P6-DATA-03), BEFORE WCK rotation — a rotation failure can never orphan the trust write, and the trust event's seq precedes the new epoch's grants.
  • Apply: mustVerifyEvent for both types (signer must be locally approved); sticky/monotonic — only pending/approved rows flip (pending→revoked is the fail-closed direction), revoked↔lost churn no-ops, the local device never flips from a remote event (a hub cannot talk a device into distrusting itself); unknown targets get a revoked placeholder (device records don't sync); needs_rotation flagged only on an actual state change so replays never re-flag cleared rotations.
  • device.approved deliberately NOT propagated — a synced approval would let one compromised device enroll attackers fleet-wide; approval stays the local P4-SEC-04 fingerprint ceremony.
  • Mutual revocation: deterministic within one batch (HLC-earlier revoke wins; the counter-revoke quarantines once its signer flips). Cross-window bystander divergence is a documented accepted residual — fail-closed either way, loud (the loser is preserved in an open conflict), recovered by the two-step local re-approval (re-approving one side replays its counter-revoke).

Dual review record

  • Codex adversarial review ran pre-push, two HIGH findings, dispositions in the work log: (1) revoke succeeds when WCK rotation fails — fail-closed-revoke REJECTED (refusing the revoke keeps a compromised device approved, strictly worse; the warn path is pre-existing P4-SEC-07); accepted the loudness half — the CLI now names the confidentiality exposure and the keys rotate remedy, spec/15 documents the residual, fail-closed/auto-retry rotation filed as follow-up. (2) cross-window mutual-revocation split — the accepted residual, but the review exposed an undocumented recovery trap (re-approval replays the counter-revoke), now pinned by TestApplyMutualRevocationCrossWindowDivergesLoudly and documented as the two-step recovery in spec/07.
  • Independent Claude reviewer pass to follow in-thread.

Tests

  • internal/sync/trust_apply_test.go: flip+rotation flagging, sticky replay no-reflag, unknown-target placeholder, untrusted-signer quarantine, local-target no-op, single-batch mutual-revocation determinism (both orders), post-revoke same-batch quarantine isolation, cross-window divergence loudness.
  • TestApplyRemoteDeviceTrustTxMatrix (state) — transition matrix incl. remote-approve rejection; TestDeviceRevokeEmitsTrustEvent (cli) — same-tx emission.
  • e2e sync_trust_propagation.txtar: three devices, full mutual pinning; A revokes B → C learns via sync (devices list + doctor rotation flags), B's subsequent push quarantines on C.
  • sync_never_granted_epoch_wedge.txtar expectations updated: the revoke event also rides the post-rotation epoch, so a never-granted device now quarantines two carriers (recovery still ends at "No open conflicts").

Validation

gofmt clean · darwin+linux builds · golangci-lint 0 issues · go test -race ./... all ok (rebased tree) · spec-drift passes (16 files). Specs: spec/00/07/09/13/15/16 stale claims retired; spec/14 TRUST-01 flipped shipped.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Device trust changes now propagate fleet-wide: revoked and lost are synced to other devices on their next sync, with fail-closed verification.
  • Bug Fixes
    • Receiving devices apply trust updates using sticky/monotonic semantics, quarantining affected events instead of applying them.
    • “Needs rotation” flags are preserved across env profile updates.
    • If workspace key rotation fails during revoke, the warning now includes clearer guidance.
  • Documentation
    • Updated specs and CLI docs to reflect shipped trust propagation and quarantine/rotation expectations.
  • Tests
    • Added unit, CLI, and end-to-end sync trust propagation scenarios.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bd9da09-cfc5-4db7-a2ec-be747a733606

📥 Commits

Reviewing files that changed from the base of the PR and between e402958 and a994d17.

📒 Files selected for processing (1)
  • internal/sync/events.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sync/events.go

📝 Walkthrough

Walkthrough

Adds synced device-trust propagation: new device.revoked/device.lost events, transactional store application with monotonic trust updates and rotation flagging, CLI revoke/lost emission, e2e coverage, and spec updates marking TRUST-01 shipped.

Changes

Device trust propagation (TRUST-01)

Layer / File(s) Summary
Event types and apply handling
internal/sync/events.go
Adds device.revoked/device.lost, DeviceTrustPayload, NewDeviceTrustEvent, and apply logic that decodes payloads, ensures remote device rows, and applies trust updates with conditional rotation flagging.
State store transactional trust methods
internal/state/store.go, internal/state/store_test.go
Wraps local trust writes in transactions, adds remote trust application and transactional rotation-flagging helpers, preserves needs_rotation across env-profile replacement, broadens must-verify event types, and adds matrix/preservation tests.
CLI revoke/lost transactional wiring
internal/cli/devices.go, internal/cli/devices_test.go
Writes revoke/lost trust flips and local trust events in one transaction, updates stderr propagation/rotation messages, and adds a CLI test for the emitted trust event.
Sync trust application tests
internal/sync/trust_apply_test.go
Adds helpers plus tests for revoke/lost application, replay behavior, unknown targets, untrusted signers, local no-ops, mutual revocation ordering, and conflict divergence.
End-to-end scenarios
cmd/devstrap/testdata/script/sync_trust_propagation.txtar, cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar
Adds a multi-device revoke-propagation script and updates an existing wedge scenario’s expected conflict count.
Spec and documentation updates
spec/00_START_HERE.md, spec/07_NAMESPACE_AND_SYNC_MODEL.md, spec/09_SECRETS_AND_ENVIRONMENT.md, spec/13_CLI_DAEMON_API.md, spec/14_MVP_ROADMAP_AND_BACKLOG.md, spec/15_SECURITY_THREAT_MODEL.md, spec/16_TEST_PLAN.md, spec/18_WORK_LOG.md
Marks TRUST-01 shipped, documents propagation and verification semantics, updates threat-model and test-plan text, and logs the work entry.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately names the main change: synced device-trust propagation for devices revoke/lost.
Description check ✅ Passed The description covers the required summary and tests sections and includes validation/safety notes, though the safety checklist isn't formatted as the template.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/trust-01

Comment @coderabbitai help to get the list of available commands.

@Reederey87 Reederey87 enabled auto-merge (squash) July 6, 2026 00:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/sync/trust_apply_test.go (2)

176-192: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Strengthen local-target no-op assertion.

Line 189 only rejects "revoked"/"lost", so a regression that instead flips local trust to some other unexpected value (e.g. "pending") would slip through undetected. Since the local device's initial trust state is known/deterministic from newSyncStore, assert equality to the expected unchanged value instead of an exclusion check.

Suggested tightening
-	if got := deviceTrustState(t, device.ID); got == "revoked" || got == "lost" {
-		t.Fatalf("local device trust=%q, must never flip from a remote event", got)
-	}
+	if got := deviceTrustState(t, st, device.ID); got != "approved" {
+		t.Fatalf("local device trust=%q, want unchanged approved (must never flip from a remote event)", got)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sync/trust_apply_test.go` around lines 176 - 192, The test
TestApplyDeviceTrustLocalTargetIsNoOp only excludes a couple of bad trust
values, so it can miss other unintended changes. Update the assertion to compare
deviceTrustState(t, st, device.ID) against the expected unchanged initial trust
value from newSyncStore, using the existing identifiers ApplyEventsWithStats,
deviceTrustState, and device.ID to keep the no-op check strict.

89-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test exercises cross-type stickiness, not literal event replay/idempotency.

The comment/test name ("replay") implies redelivery of the same event, but this test actually applies a different event (lost, new ID evt_lost_b) against an already-revoked target — it verifies monotonic stickiness across event types, not idempotent handling of a duplicate/re-delivered event with the same ID/content. Per the stack context, this file is expected to cover "replay idempotency" for the trust events; consider adding a companion case that re-applies the exact same revoke event (same ID) to confirm the dedup/no-reflag path for genuine redelivery, distinct from this cross-type stickiness case.

Suggested additional case
// Re-apply the identical revoke event (redelivery) — must not re-flag rotation.
if _, err := ApplyEvents(ctx, st, []state.Event{revoke}); err != nil {
	t.Fatal(err)
}
n2, err := st.CountSecretBindingsNeedingRotation(ctx)
if err != nil {
	t.Fatal(err)
}
if n2 != 0 {
	t.Fatalf("needs_rotation count=%d, want 0 on literal replay", n2)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sync/trust_apply_test.go` around lines 89 - 131, The current test
named TestApplyDeviceTrustReplayDoesNotReflagRotation is validating cross-type
stickiness by applying a new EventDeviceLost event, not literal
replay/idempotency. Keep this case for monotonic behavior, but add a separate
assertion in the same trust flow that re-applies the exact same revoke event
(same event ID via signedDeviceTrustEvent and ApplyEvents) and verifies
CountSecretBindingsNeedingRotation stays at zero. This will cover true
redelivery handling distinct from the existing revoked-to-lost scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@spec/07_NAMESPACE_AND_SYNC_MODEL.md`:
- Line 749: Update the TRUST-01 paragraph in the namespace/sync model spec so it
no longer claims no sync/remote path can inject a revoked/lost row. Rephrase the
statement to limit that guarantee to locally created revoked/lost rows, while
acknowledging that propagated device.revoked/device.lost states from the sticky
apply path can reach peers. Keep the fail-closed explanation aligned with the
existing symbols TestHasEnrolledDevicesStickyAfterRevoke,
TestApplyEventsRevokedLastDeviceStaysFailClosed, EnsureRemoteDeviceTx, and
hasEnrolledDevices.

---

Nitpick comments:
In `@internal/sync/trust_apply_test.go`:
- Around line 176-192: The test TestApplyDeviceTrustLocalTargetIsNoOp only
excludes a couple of bad trust values, so it can miss other unintended changes.
Update the assertion to compare deviceTrustState(t, st, device.ID) against the
expected unchanged initial trust value from newSyncStore, using the existing
identifiers ApplyEventsWithStats, deviceTrustState, and device.ID to keep the
no-op check strict.
- Around line 89-131: The current test named
TestApplyDeviceTrustReplayDoesNotReflagRotation is validating cross-type
stickiness by applying a new EventDeviceLost event, not literal
replay/idempotency. Keep this case for monotonic behavior, but add a separate
assertion in the same trust flow that re-applies the exact same revoke event
(same event ID via signedDeviceTrustEvent and ApplyEvents) and verifies
CountSecretBindingsNeedingRotation stays at zero. This will cover true
redelivery handling distinct from the existing revoked-to-lost scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 325ee8e6-d1c0-46d4-9063-e6454a5bca64

📥 Commits

Reviewing files that changed from the base of the PR and between 3a6c266 and 9d53407.

📒 Files selected for processing (16)
  • cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar
  • cmd/devstrap/testdata/script/sync_trust_propagation.txtar
  • internal/cli/devices.go
  • internal/cli/devices_test.go
  • internal/state/store.go
  • internal/state/store_test.go
  • internal/sync/events.go
  • internal/sync/trust_apply_test.go
  • spec/00_START_HERE.md
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/13_CLI_DAEMON_API.md
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md

Comment thread spec/07_NAMESPACE_AND_SYNC_MODEL.md
devices revoke/lost now emit synced device.revoked/device.lost events
(mustVerify, same transaction as the local trust flip, before WCK
rotation), so one device's revoke reaches the whole fleet: receiving
devices flip the target sticky/monotonically (only pending/approved
transition; the local device never flips from a remote event; only the
local fingerprint ceremony resurrects) and flag needs_rotation exactly
once (gated on an actual change — replays never re-flag cleared
rotations).

device.approved is deliberately NOT propagated: a synced approval would
let one compromised device enroll attackers fleet-wide; approval stays
the local P4-SEC-04 ceremony.

Mutual revocation converges deterministically within one batch
(HLC-earlier revoke wins; the counter-revoke quarantines once its
signer flips); cross-window bystander divergence is a documented
accepted residual — fail-closed either way, loud, operator re-approves
the survivor.

Closes the P6-SYNC-01/P6-SYNC-03 trust-propagation residual (spec/07,
spec/15). e2e: sync_trust_propagation.txtar (three devices).
sync_never_granted_epoch_wedge.txtar expectations updated: the revoke
event also rides the post-rotation epoch, so a never-granted device
quarantines two carriers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/state/store.go`:
- Around line 1770-1792: The sqlclosecheck failure comes from manually closing
rotRows inside UpsertEnvProfileTx after reading secret_bindings rotation flags.
Move the query/scan logic into a small helper such as readBindingRotationFlags
that uses defer Close on the rows and returns the map before the DELETE/INSERT
work continues. Keep the existing ordering in UpsertEnvProfileTx so the result
set is fully consumed and closed before tx.tx.ExecContext runs, and reuse the
helper’s error wrapping for read/scan/close failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b1dc702f-3a7a-4c86-af9c-64efc3b49766

📥 Commits

Reviewing files that changed from the base of the PR and between 9d53407 and aa7210c.

📒 Files selected for processing (16)
  • cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar
  • cmd/devstrap/testdata/script/sync_trust_propagation.txtar
  • internal/cli/devices.go
  • internal/cli/devices_test.go
  • internal/state/store.go
  • internal/state/store_test.go
  • internal/sync/events.go
  • internal/sync/trust_apply_test.go
  • spec/00_START_HERE.md
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/13_CLI_DAEMON_API.md
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md
✅ Files skipped from review due to trivial changes (6)
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/00_START_HERE.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md
  • spec/13_CLI_DAEMON_API.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar
  • cmd/devstrap/testdata/script/sync_trust_propagation.txtar
  • internal/cli/devices.go
  • internal/cli/devices_test.go
  • spec/15_SECURITY_THREAT_MODEL.md
  • internal/sync/trust_apply_test.go
  • internal/sync/events.go

Comment thread internal/state/store.go Outdated
…load quarantine

- spec/07 shipped-event catalog gains device.revoked/device.lost (they had
  only been removed from the planned block).
- A signed-but-malformed trust payload (decode failure / empty target) now
  wraps ErrEventVerification so it quarantines-as-consumed instead of
  aborting the pull batch — only an approved signer can produce one, which
  is exactly the compromised-device class these events cut off. Pinned by
  TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87

Copy link
Copy Markdown
Owner Author

Dual-review record (complete).

  • Codex adversarial (pre-push): two HIGH findings, dispositions in the PR body/work log — louder rotation-failure warning + spec/15 residual + follow-up devices revoke: fail-closed or auto-retrying WCK rotation (adversarial-review follow-up) #134; cross-window divergence pinned by test + two-step recovery documented.
  • Live R2 dogfood (spec/19 §F.3 forthcoming) caught a real bug: the superseding rewrap event wiped needs_rotation fleet-wide — fixed in aa7210c (per-var flag preservation, TestUpsertEnvProfileTxPreservesNeedsRotation, hardened txtar assertion). Fresh-workspace re-run confirms the rotation warning now survives on the revoker AND propagates to bystanders, and hydrate still works through the superseded ref.
  • Independent opus reviewer (commit db2c0b7): no Blocker/Major; verification gate, sticky apply, transaction boundaries, and the 1→4 wedge-count change all verified sound (targeted checks a–d in the report). Two Minors fixed in e717d4d: spec/07 shipped-event catalog gains both trust types; a signed-but-malformed trust payload now quarantines-as-consumed (ErrEventVerification wrap) instead of aborting the batch — pinned by TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort. The env/draft malformed-payload convention is tracked in sync: draft-snapshot apply hard-errors on unknown project and can abort a whole pull batch #133.

Gates re-run on the final tree: race tests green (sync/state/cli), wedge + propagation txtars green, spec-drift passes.

…odeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sync/events.go`:
- Around line 898-900: The trust event decode path in the JSON unmarshal
handling is formatting the inner error with %v instead of wrapping it, so update
the return in the event payload decode block to preserve the underlying JSON
error in the chain. Use the json.Unmarshal failure branch in the trust event
processing code and change the fmt.Errorf call so the wrapped error is carried
through with %w while keeping the existing state.ErrEventVerification and
event.ID context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 815e1ce2-d21e-46aa-a672-5df485c2ba60

📥 Commits

Reviewing files that changed from the base of the PR and between aa7210c and e402958.

📒 Files selected for processing (5)
  • internal/state/store.go
  • internal/sync/events.go
  • internal/sync/trust_apply_test.go
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/18_WORK_LOG.md
✅ Files skipped from review due to trivial changes (1)
  • spec/18_WORK_LOG.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/sync/trust_apply_test.go
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • internal/state/store.go

Comment thread internal/sync/events.go
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 merged commit c49e090 into main Jul 6, 2026
7 checks passed
@Reederey87 Reederey87 deleted the feat/trust-01 branch July 6, 2026 00:43
Reederey87 added a commit that referenced this pull request Jul 6, 2026
Live-R2 validation log for the ENV-SYNC-01 + TRUST-01 wave (PRs #130#132):
pairing-before-capture ordering, byte-identical cross-device hydrate,
revocation propagation via sync alone, post-revocation quarantine + epoch
wedge-out, rotation-warning propagation, and the dogfood-caught
needs_rotation wipe (fixed in PR #132).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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