Skip to content

fix(sync): draft-snapshot apply quarantines instead of aborting the pull batch (#133)#136

Merged
Reederey87 merged 1 commit into
mainfrom
fix/i133-draft-pending-quarantine
Jul 6, 2026
Merged

fix(sync): draft-snapshot apply quarantines instead of aborting the pull batch (#133)#136
Reederey87 merged 1 commit into
mainfrom
fix/i133-draft-pending-quarantine

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Fixes #133.

What

The draft.snapshot.created apply case in applyEventTx hard-errored on an unknown project, and that raw error aborted the entire ApplyEventsWithStats batch and froze the pull cursor — one stale/early draft pointer (e.g. its project.added sitting in a verification quarantine because the origin device is unpinned here) wedged the whole sync.

How

Mirrors the env-pointer shape shipped in the ENV-SYNC-01 wave (#131/#132):

  • Winning tombstone drops the pointer (the delete won; a re-add + re-snapshot re-emits).
  • Absent-without-tombstone quarantines as a replayable, cursor-consuming draft_pending_project conflict (new kind beside env_pending_project; shared insertPendingProjectConflict). The batch never aborts.
  • The env replay generalizes to ReplayPendingProjectConflicts covering both kinds — run after every pull apply (internal/cli/sync.go) and at approve-time replay (internal/cli/devices.go); draft re-apply re-runs RecordDraftSnapshotTx through the normal verified path and resolves the conflict only after a successful apply.
  • Malformed-payload convention (the issue's same-class residual, both planes): a signed-but-malformed draft or env payload — JSON decode failure, unsafe pathkey.Clean path, or (opus review) a blob ref that can never pass age_blob: validation — now wraps state.ErrEventVerification at the apply layer, quarantining-as-consumed instead of aborting the batch or error-looping the pending replay (mirrors the PR feat(devices): synced device-trust propagation (TRUST-01) #132 trust-payload convention; only an APPROVED signer can reach these branches). The env-side wraps and blob-ref validation were coordinator review additions on top of the delegated draft-side fix.
  • Chain interaction pinned, not fixed (Codex review): a pending-quarantined pointer is consumed for the cursor but never inserted into events, so the origin device's next chained event breaks on validatePrevEventHash and holds that device's cursor. Verified to be a bounded temporary hold with existing recovery (the real CLI can never emit a draft before its own project.added; once the project lands, replay inserts the pointer, the re-delivered successor applies, and its hash-chain conflict auto-resolves via the P6-SEC-03 path) — pinned end-to-end by TestApplyDraftSnapshotPendingChainSuccessorRecovers and documented in spec/07.

Tests

TestApplyDraftSnapshotUnknownProjectQuarantinesWithoutAbort (batch continues, cursor advances, conflict kind pinned), TestApplyDraftSnapshotTombstonedProjectDrops, TestApplyDraftSnapshotMalformedPayloadQuarantinesWithoutAbort, TestApplyDraftSnapshotBadBlobRefQuarantinesWithoutAbort, TestApplyEnvProfileMalformedPayloadQuarantinesWithoutAbort, TestReplayPendingDraftSnapshotConflictRecovers, TestApplyDraftSnapshotPendingChainSuccessorRecovers.

Specs

spec/07 (P6-SYNC-01 conflict-kind list + replay + chain-hold note), spec/09 (renamed replay), spec/13 (sync pending-project replay sentence), spec/16 (test inventory), spec/18 work log.

Validation

gofmt clean · golangci-lint run 0 issues · go test -race ./... ok · spec-drift --base origin/main pass · dual review (coordinator line-by-line + gpt-5.5 implementation + opus reviewer + Codex review).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Sync now handles pending project-pointer conflicts more reliably, allowing batches to continue instead of stopping.
    • Draft snapshot and profile updates with missing projects are now quarantined for later recovery rather than failing immediately.
    • Malformed or unresolvable pointer payloads are skipped safely and no longer block sync progress.
    • After pull/apply and device approval flows, pending conflicts are replayed automatically so newly available project data can be applied.

…ull batch (#133)

The draft.snapshot.created apply case hard-errored on an unknown project
and the raw error aborted the whole ApplyEventsWithStats batch, freezing
the pull cursor. Mirror the env pointer shape: a winning tombstone drops
the pointer; an absent, non-tombstoned project quarantines as a
replayable, cursor-consuming draft_pending_project conflict; the env
replay generalizes to ReplayPendingProjectConflicts covering both kinds.
Signed-but-malformed draft/env payloads (decode failure, unsafe path,
bad blob ref) wrap state.ErrEventVerification and quarantine-as-consumed
on both planes. The pending-pointer chain-successor hold is pinned as a
bounded, recoverable interaction (Codex review), not a wedge.

Fixes #133

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 enabled auto-merge (squash) July 6, 2026 10:52
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Draft snapshot apply handling is changed to mirror env-profile behavior: missing non-tombstoned projects now quarantine the pointer event as a replayable conflict instead of aborting the batch, while tombstoned projects silently drop it. ReplayPendingEnvProfileConflicts is replaced with ReplayPendingProjectConflicts, which handles both env and draft pending conflicts; CLI callers, tests, and specs are updated accordingly.

Changes

Pending Project Conflict Generalization

Layer / File(s) Summary
Core pending-project quarantine and replay logic
internal/sync/events.go
Adds EventConflictKindDraftPendingProject, generalizes conflict insertion via insertPendingProjectConflict, updates applyEventTx for draft/env validation with tombstone-drop vs pending-quarantine distinction, and replaces env-only replay with ReplayPendingProjectConflicts.
CLI callers switch to ReplayPendingProjectConflicts
internal/cli/sync.go, internal/cli/devices.go
Updates pullAndApplyEvents and replayQuarantinedEvents to call the generalized replay function with updated messaging.
Tests for draft snapshot quarantine and replay
internal/sync/apply_test.go
Adds helpers and tests covering unknown-project quarantine, tombstone drop, bad blob ref handling, chain successor recovery, malformed payload quarantine, and conflict replay for draft snapshots; updates existing env-profile test.
Spec and work log updates
spec/07_NAMESPACE_AND_SYNC_MODEL.md, spec/09_SECRETS_AND_ENVIRONMENT.md, spec/13_CLI_DAEMON_API.md, spec/16_TEST_PLAN.md, spec/18_WORK_LOG.md
Documents the generalized pending-project conflict replay behavior and records a work log entry closing issue #133.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Sync as CLI sync/devices approve
  participant Apply as ApplyEventsWithStats
  participant Handler as applyEventTx
  participant Store as State Store
  participant Replay as ReplayPendingProjectConflicts

  Sync->>Apply: apply pulled events (draft/env pointer)
  Apply->>Handler: process EventDraftSnapshotCreated / EventEnvProfileUpdated
  Handler->>Store: lookup project by path
  alt project tombstoned
    Handler-->>Apply: drop pointer silently
  else project missing (not tombstoned)
    Handler-->>Apply: pending-project error
    Apply->>Store: insert pending conflict (env/draft_pending_project)
  end
  Sync->>Replay: replay pending project conflicts
  Replay->>Store: fetch open pending conflicts
  Replay->>Handler: re-apply stored pointer event
  Handler-->>Replay: success
  Replay->>Store: resolve conflict
Loading

Related issues: #133

Suggested labels: sync, bug-fix, needs-tests-verified

Suggested reviewers: Reederey87

🐰 A pointer once quarantined, no longer stuck in dread,
Waits patiently in conflict, till its project's thread is spread,
Draft and env now share one path, replayed with steady grace,
No batch shall halt, no cursor stall — recovery finds its place.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: draft-snapshot apply now quarantines instead of aborting the pull batch.
Description check ✅ Passed The description covers the fix, implementation, tests, specs, and validation, though it doesn't follow the template headings and checklist format exactly.
Linked Issues check ✅ Passed The changes implement #133 by quarantining unknown-project draft snapshots, adding replay for pending project conflicts, and preserving batch progress.
Out of Scope Changes check ✅ Passed The code, tests, and spec updates stay focused on the draft-snapshot quarantine and replay fix, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 fix/i133-draft-pending-quarantine

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

@Reederey87 Reederey87 merged commit 89aad55 into main Jul 6, 2026
6 of 7 checks passed
@Reederey87 Reederey87 deleted the fix/i133-draft-pending-quarantine branch July 6, 2026 10:58

@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 1000-1011: The pending-project replay loop in
internal/sync/events.go is applying conflicts in the order returned by
OpenConflictsByType instead of event order, which can let a newer draft override
an older one when RecordDraftSnapshotTx updates current_snapshot_id. Collect the
pending-project conflicts first, sort them by the restored event’s order
metadata (or equivalent event sequence/time field available on the unmarshaled
state.Event/details), and then call ApplyEventsWithStats in that sorted order so
older drafts are replayed before newer ones.
🪄 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: 65c627bb-b78b-4807-a519-b969c6653cbb

📥 Commits

Reviewing files that changed from the base of the PR and between 6b68367 and 8f60f8a.

📒 Files selected for processing (9)
  • internal/cli/devices.go
  • internal/cli/sync.go
  • internal/sync/apply_test.go
  • internal/sync/events.go
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/13_CLI_DAEMON_API.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md

Comment thread internal/sync/events.go
Comment on lines 1000 to 1011
for _, c := range conflicts {
var details eventVerificationConflictDetails
if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || details.Kind != EventConflictKindEnvPendingProject {
if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || !isPendingProjectConflictKind(details.Kind) {
continue
}
var restored state.Event
if err := json.Unmarshal([]byte(details.EventJSON), &restored); err != nil {
logging.Logger(ctx).Warn("env-pending replay: conflict carries unparseable event JSON",
"conflict_id", c.ID, "event_id", details.EventID, "err", err.Error())
logging.Logger(ctx).Warn("pending-project replay: conflict carries unparseable event JSON",
"conflict_id", c.ID, "kind", details.Kind, "event_id", details.EventID, "err", err.Error())
continue
}
_, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{restored}, nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Replay pending pointers in event order before applying them.

Line 1000 currently replays conflicts in OpenConflictsByType order (details_json, id), not event order. For multiple draft_pending_project conflicts on the same project, this can apply a newer draft before an older one; RecordDraftSnapshotTx then unconditionally updates current_snapshot_id, so the older replay can become the current draft.

Proposed fix
-	replayed := 0
+	type pendingProjectReplay struct {
+		conflict state.Conflict
+		details  eventVerificationConflictDetails
+		event    state.Event
+	}
+	var pending []pendingProjectReplay
 	for _, c := range conflicts {
 		var details eventVerificationConflictDetails
 		if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || !isPendingProjectConflictKind(details.Kind) {
 			continue
 		}
 		var restored state.Event
 		if err := json.Unmarshal([]byte(details.EventJSON), &restored); err != nil {
 			logging.Logger(ctx).Warn("pending-project replay: conflict carries unparseable event JSON",
 				"conflict_id", c.ID, "kind", details.Kind, "event_id", details.EventID, "err", err.Error())
 			continue
 		}
+		pending = append(pending, pendingProjectReplay{conflict: c, details: details, event: restored})
+	}
+	sort.Slice(pending, func(i, j int) bool {
+		if pending[i].event.HLC == pending[j].event.HLC {
+			if pending[i].event.DeviceID == pending[j].event.DeviceID {
+				return pending[i].event.ID < pending[j].event.ID
+			}
+			return pending[i].event.DeviceID < pending[j].event.DeviceID
+		}
+		return pending[i].event.HLC < pending[j].event.HLC
+	})
+	replayed := 0
+	for _, replay := range pending {
+		c := replay.conflict
+		details := replay.details
+		restored := replay.event
 		_, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{restored}, nil)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, c := range conflicts {
var details eventVerificationConflictDetails
if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || details.Kind != EventConflictKindEnvPendingProject {
if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || !isPendingProjectConflictKind(details.Kind) {
continue
}
var restored state.Event
if err := json.Unmarshal([]byte(details.EventJSON), &restored); err != nil {
logging.Logger(ctx).Warn("env-pending replay: conflict carries unparseable event JSON",
"conflict_id", c.ID, "event_id", details.EventID, "err", err.Error())
logging.Logger(ctx).Warn("pending-project replay: conflict carries unparseable event JSON",
"conflict_id", c.ID, "kind", details.Kind, "event_id", details.EventID, "err", err.Error())
continue
}
_, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{restored}, nil)
type pendingProjectReplay struct {
conflict state.Conflict
details eventVerificationConflictDetails
event state.Event
}
var pending []pendingProjectReplay
for _, c := range conflicts {
var details eventVerificationConflictDetails
if json.Unmarshal([]byte(c.DetailsJSON), &details) != nil || !isPendingProjectConflictKind(details.Kind) {
continue
}
var restored state.Event
if err := json.Unmarshal([]byte(details.EventJSON), &restored); err != nil {
logging.Logger(ctx).Warn("pending-project replay: conflict carries unparseable event JSON",
"conflict_id", c.ID, "kind", details.Kind, "event_id", details.EventID, "err", err.Error())
continue
}
pending = append(pending, pendingProjectReplay{conflict: c, details: details, event: restored})
}
sort.Slice(pending, func(i, j int) bool {
if pending[i].event.HLC == pending[j].event.HLC {
if pending[i].event.DeviceID == pending[j].event.DeviceID {
return pending[i].event.ID < pending[j].event.ID
}
return pending[i].event.DeviceID < pending[j].event.DeviceID
}
return pending[i].event.HLC < pending[j].event.HLC
})
replayed := 0
for _, replay := range pending {
c := replay.conflict
details := replay.details
restored := replay.event
_, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{restored}, nil)
🤖 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/events.go` around lines 1000 - 1011, The pending-project replay
loop in internal/sync/events.go is applying conflicts in the order returned by
OpenConflictsByType instead of event order, which can let a newer draft override
an older one when RecordDraftSnapshotTx updates current_snapshot_id. Collect the
pending-project conflicts first, sort them by the restored event’s order
metadata (or equivalent event sequence/time field available on the unmarshaled
state.Event/details), and then call ApplyEventsWithStats in that sorted order so
older drafts are replayed before newer ones.

Reederey87 added a commit that referenced this pull request Jul 6, 2026
spec/14 DIRECTION bullet marking the wave complete (draft-pending
quarantine #133, self-healing WCK rotation #134, squash-merge worktree
GC P4-GIT-04, devstrap service P4-PROD-04) with the post-wave backlog
and the Pass-7 audit checkpoint; ledger intro trail extended.

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.

sync: draft-snapshot apply hard-errors on unknown project and can abort a whole pull batch

1 participant