Skip to content

phase 6b replay orchestration - #1112

Open
kans wants to merge 1 commit into
mainfrom
kans/phase-6b-replay-orchestration
Open

phase 6b replay orchestration#1112
kans wants to merge 1 commit into
mainfrom
kans/phase-6b-replay-orchestration

Conversation

@kans

@kans kans commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

sync: source-cache replay orchestration (Phase 6b)

Connectors that can cheaply revalidate upstream data (ETags, delta tokens)
can now skip refetching unchanged scopes: the SDK hands them their previous
validator, and on "unchanged" replays the previous sync's rows locally
instead of paging them from the API.

Core invariant: a warm sync produces the identical artifact a cold sync
would have, or fails loudly with a warm/cold ErrReplayIntegrity verdict —
never a silent blend of stale and fresh rows. Replay is gated on artifact
eligibility (Pebble, finished FULL, clean quality, byte-matching compat
record, materialization witness) and same-sync lookup provenance; anything
off degrades to a cold fetch.

Not yet live in production: subprocess connectors can't receive the
lookup, so capable connectors produce (stamp rows, publish validators) but
consume cold. Phase 6c adds cross-process delivery and the runner retry
ladder.

Verified per docs/verification/sync-replay-6b/plan.md (frozen before
code) — chaos suites over the real syncer and stores with differential
oracles against cold baselines, interruption/resume, generational chains,
all -race; plus three independent reviews whose findings are fixed and
instrumented (CO-6b-003). Evidence in evidence.md. Hot-path cost when the
capability is absent: ~100ns per page.

@kans kans changed the title Kans/phase 6b replay orchestration phase 6b replay orchestration Aug 27, 2026
Comment thread pkg/sync/state.go Outdated
Comment thread pkg/sync/source_cache_orchestration.go Outdated
// A resume whose gates degraded to cold (compat drift, withdrawn or
// swapped previous artifact) must not honor hits recorded by an earlier
// attempt against a base this attempt never re-validated.
if !o.s.sourceCacheWarm {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (confidence: medium): this cold verdict has no consumer yet — ridesReplayLadder is pinned false and nothing in-tree handles ErrReplayIntegrity / ReplayVerdictCold (Phase 6c runner work). Because the offending replay verdict lives in a checkpointed EnqueuePageTokens cursor, a degrade that flips sourceCacheWarm to false mid-sync (compat drift on resume, previous artifact becoming ineligible) makes every subsequent resume re-serve the same cursor and fail identically: the sync is permanently stuck rather than degrading to a cold retry. Worth either noting the operational contract in the doc comment or having the drift/degrade path also clear the checkpointed hit-set so the connector's cursors re-plan cold.

Comment thread docs/rfcs/0010-sqlite-conversion-only.md Outdated
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

General PR Review: phase 6b replay orchestration

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base ad26360edd59.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (59 files, +10266/-389) for security and correctness, reading every non-test production file end to end: pkg/sync/source_cache_orchestration.go, the syncer.go/state.go seams, pkg/sourcecache, the pkg/dotc1z Pebble compat/clear surfaces, the envelope header decoder, and the proto + regenerated pb/ output. No dependency manifests changed. No security issues and no confident correctness bug found; the fail-closed structure (cold-by-default verdicts, warm-flag provenance gate, compat byte-match, per-scope locking, attempt-local grounding) holds up against the resume, parallel-worker, and verdict-flip paths I traced, and the checkpoint-granularity contract (CO-6b-002: interrupted actions restart at their root page token) is what makes groundRecordScope safe for multi-page rounds. The prior finding on chaos_harness_test.go's non-asserting sdkSyncer.(*syncer) type switch is still open — the ride-along guard remains if concrete, ok := ...; ok, so it is unaddressed rather than re-flagged here.

Risk triage per docs/BUG_CATCHING.md §2 — HIGH: silent (a wrong replay produces well-formed stale rows, not a failure), durable (c1z manifest entries, compat records, checkpointed sync tokens, a new proto wire field), version-pair dependent (the CO-017 fold fence is precisely a which-SDK-wrote-this question), and consumer-distant (future SDKs and downstream connectors read the artifact); worst credible remediation is rung 3–4. The PR already carries the instruments that verdict demands — differential oracles against cold baselines, interruption/resume and generational chain suites under -race, a formal trace-policy bridge, cost benchmarks on the checkpoint and page-ops paths — and the produce/consume split keeps it dark in production this phase, so the escalation ask is satisfied in-tree rather than outstanding.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/sync/source_cache_orchestration.go:634 — the replay branch does not consult sourceCacheScopeGrounded, so a replay page following a record round for the same scope in one attempt silently discards the record round's fresh rows (the mirror direction is guarded).
  • pkg/sync/source_cache_orchestration.go:293sourceCacheStore is reset per attempt and sourceCacheWarm on teardown, but sourceCacheScopeGrounded / sourceCacheScopeLocks never are; latent hazard if a syncer is ever reused across Sync calls.
  • pkg/sync/state.go:69 — exported State gains four methods (and dotc1z.SourceCacheStore three) with pkg/sdk/version.go unchanged; a source-compat break for any downstream implementer, against this file's own pushAction precedent.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/sync/source_cache_orchestration.go`:
- Around line 634-643 (sourceCachePageOps.beforeUpserts, replay branch): the
  replay path checks the warm flag, the recorded hit validator, and
  state.SourceCacheReplayed, but never checks s.sourceCacheScopeGrounded. The
  record->replay ordering is therefore unguarded while replay->record is
  guarded. If a record-annotated page for scope S is applied first this attempt
  (groundRecordScope clears, rows are upserted, no validator published yet) and
  a later page for the same (rowKind, scopeKey) carries SourceCacheReplay,
  SourceCacheReplayed(S) is still false, so ReplaySourceCache runs its
  replacement copy and wipes the record round's fresh rows with no error and no
  warning. Fix: before running the copy, load
  s.sourceCacheScopeGrounded[sourceCacheScopeKey(rowKind, scopeKey)]; if it is
  present and state.SourceCacheReplayed is false, return a
  newReplayIntegrityError(ReplayVerdictCold, ...) explaining that the scope was
  already established by a record round this attempt, so a replacement copy
  would discard fresh rows. At minimum, log a Warn on that path.
- Around line 293 (installSourceCacheLookup): the function resets
  s.sourceCacheStore = nil at the top of every attempt and the returned teardown
  resets s.sourceCacheWarm = false, but s.sourceCacheScopeGrounded and
  s.sourceCacheScopeLocks (both native_sync.Map) are never reset. Sync's
  ClearIngestInvariantVerification comment explicitly contemplates "a reused
  syncer", and on a second Sync call the carried-over grounded set would make
  groundRecordScope a no-op for every scope grounded by the first sync, skipping
  the CO-6b-008 debris clear. Fix: reset both maps alongside
  s.sourceCacheStore = nil (e.g. s.sourceCacheScopeGrounded = native_sync.Map{}
  and s.sourceCacheScopeLocks = native_sync.Map{}), so the per-attempt reset is
  symmetric with the two fields that already reset. No in-tree caller reuses a
  syncer today, so this is hardening, not a live bug.

In `pkg/sync/state.go`:
- Around line 66-72: the exported State interface gains RecordSourceCacheHit,
  SourceCacheHitValidator, MarkSourceCacheReplayed, and SourceCacheReplayed.
  State is exported from pkg/sync, so any downstream implementation of it stops
  compiling. The same file's pushAction helper exists specifically to add
  behavior "without changing the exported State interface", so this deviates
  from local precedent. pkg/dotc1z/source_cache.go's exported SourceCacheStore
  interface likewise gains ClearSourceCacheScope, PutSourceCacheCompat, and
  GetSourceCacheCompat, and pkg/sdk/version.go is unchanged at v0.27.0. Fix
  options: (a) keep the four provenance accessors on the concrete *state type
  and have the syncer type-assert for them, mirroring pushAction; or (b) keep
  the interface widening and bump pkg/sdk/version.go to the next 0.x minor as
  the pre-1.0 compatibility signal, noting the break in the PR description.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

kans added a commit that referenced this pull request Aug 27, 2026
PR-review round on #1112 found the residual half of the checkpoint
provenance gap: hits were bare (rowKind, scopeKey) pairs, and no consume
gate identifies WHICH artifact a hit came from -- two artifacts from the
same connector and config carry identical compat keys, so a previous
artifact swapped between attempts passed every gate and a checkpointed
hit authorized a replacement copy from a base the connector never
revalidated.

Hits now record the validator the lookup returned (checkpoint shape:
row kind -> scope -> validator), and beforeUpserts requires the current
replay base's manifest entry to byte-match it before the copy runs;
mismatch, absent entry, read failure, and a base without the entry
surface are all cold ErrReplayIntegrity. Four taxonomy cells pin the
cold paths; every warm chaos instrument now passes through the binding.

Also from the same round: the stuck-resume operational contract for
unconsumed cold verdicts is documented on beforeUpserts (6c's ladder
automates the cold fallback), the provenance sets' checkpoint cost curve
is documented and pinned by BenchmarkStateMarshalSourceCacheSets, and
private backend-repo paths in docs/rfcs/0010 are scrubbed per the
public-repo content guidelines.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread pkg/sync/state.go
// checkpointed and what the validator binds. A later hit for the same
// scope overwrites: the connector's most recent consult is the one whose
// verdict its cursors carry.
func (st *state) RecordSourceCacheHit(rowKind sourcecache.RowKind, scopeKey string, cacheValidator string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (medium confidence): the documented last-write-wins overwrite reopens the swapped-base hole in the one case CO-6b-004 targets. If attempt 1 enqueues sibling cursors carrying replay verdicts computed against base A (validator V_A) and then crashes before the planning action completes, attempt 2 re-runs the planning call against swapped base B, and RecordSourceCacheHit overwrites scopes[scopeKey] to V_B. The checkpointed attempt-1 cursors then pass beforeUpserts' binding check (baseEntry.CacheValidator == hitValidator at source_cache_orchestration.go:633) and copy B's rows under a verdict the connector never computed against B — the exact "silently stale rows in a green sync" the binding is meant to reject. Consider keeping the first recorded validator per scope (or failing cold when a re-consult returns a different validator for an already-recorded scope) so the binding stays anchored to the artifact the verdict was actually computed against.

Comment thread pkg/sync/state.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread pkg/sync/source_cache_orchestration.go Outdated
select {
case <-store.entered:
t.Fatal("second replay copy entered the store while the first was mid-flight: decide-copy-mark is not atomic per scope")
case <-time.After(300 * time.Millisecond):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (confidence: medium): this is the only instrument that pins the decide-copy-mark race itself (the chaos test admits its duplicates after the parent marked the scope, as plan.md now records), and its verdict rests on a 300 ms wall-clock window. Its failure direction is a silent pass: on a loaded runner under -race, a mutex-less second goroutine that simply isn't scheduled within 300 ms makes the test green. Worth either signaling from inside beforeUpserts that the second call reached the guard (so the window measures store entry, not goroutine startup) or recording the false-negative direction in the test comment, since evidence.md presents it as the mutation-adequate replacement for the vacuous chaos cell.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@kans
kans force-pushed the kans/phase-6b-replay-orchestration branch from 9951158 to c33f698 Compare August 28, 2026 00:41
}
mu := o.s.sourceCacheScopeLock(o.rowKind, o.scopeKey)
mu.Lock()
o.held = mu

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: after the CO-6b-006 extension, every scoped page — record-only included — holds this mutex from beforeUpserts through afterUpserts, so the critical section now spans the entire page handler. In syncGrantsForResource that includes s.getResourceFromConnector (a connector RPC per unresolved related resource, syncer.go:2923) and the grant-discovered-resource writes; in syncResources it includes the per-resource store.GetResource loop and getSubResources. Net effect: at WithWorkerCount > 1, all pages sharing a (rowKind, scopeKey) are fully serialized behind network latency, so a connector that scopes a whole resource type loses intra-scope parallelism entirely. BenchmarkSourceCacheScopeLocks measures map access, not hold time, so nothing in the evidence bounds this. Consider narrowing the lock to the decide-copy-mark plus tombstone/publish windows (with an explicit "record page in flight" marker to keep the N1 interleaving closed), or at minimum document the serialization contract on the field and add a contention benchmark. (medium confidence on impact magnitude; high confidence on the mechanism)

Comment thread pkg/sync/state.go
Comment on lines +69 to +72
RecordSourceCacheHit(rowKind sourcecache.RowKind, scopeKey string, cacheValidator string)
SourceCacheHitValidator(rowKind sourcecache.RowKind, scopeKey string) (string, bool)
MarkSourceCacheReplayed(rowKind sourcecache.RowKind, scopeKey string)
SourceCacheReplayed(rowKind sourcecache.RowKind, scopeKey string) bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: sync.State is an exported interface, and these four methods are added to it without a versioned/adapter shape — any out-of-tree implementation (test doubles, alternate state backends) stops compiling. pkg/sdk/version.go is unchanged in this PR and the description doesn't call the interface change out, which the repo's own criteria ask for on signature-level breaks. Either note it as a deliberate break with a version signal, or keep State frozen and expose the provenance accessors on a separate optional interface that *state satisfies. (medium confidence — the blast radius depends on whether anything outside this repo implements State)

// FUNCTIONALITY — see pkg/sourcecache). Never nil: when source-cache
// replay is disabled or degraded this is sourcecache.NoopLookup and
// every lookup misses.
Lookup sourcecache.Lookup

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: the "Never nil" guarantee is only enforced by pkg/connectorbuilder's four call sites (b.sourceCache() substitutes NoopLookup). SyncOpAttrs is an exported struct that connector repos construct directly in their List/Grants unit tests, and a zero value leaves Lookup as a nil interface — a connector following the doc comment and calling opts.Lookup.LookupPreviousSourceCache(...) panics there. Consider either a nil-safe accessor method on SyncOpAttrs or softening the comment to "populated by the connector builder; nil-check when constructing SyncOpAttrs yourself". (low confidence on severity, high on the mechanism)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

// path — evaluates it at test end; any scenario that errors a scoped
// page between the lock's acquire and release trips it if a handler
// loses its backstop.
if concrete, ok := sdkSyncer.(*syncer); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (medium confidence): this ride-along is the instrument that structurally retires the lost-release() class, but the if …, ok := sdkSyncer.(*syncer); ok guard makes it fail open — if the assertion ever stops holding, every chaos suite silently stops evaluating the invariant with no signal. NewSyncer returns *syncer today (syncer.go:4573), so the guard buys nothing; concrete, ok := …; require.True(t, ok, "…") would make a future wrapper return a build/test failure instead of a quietly disarmed instrument.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Single squashed implementation commit, rebased onto main after the
formal verification track (#1117) landed separately. Contains the 6b
branch through CO-6b-007 plus the production fixes made during the
formal effort:

- Capability parsing, warm/cold lookup installation with the
  deliverability probe (CO-6b-001), compat record write/gating,
  selection fingerprint, checkpointed hit/replay provenance with the
  warm gate, per-scope locks (CO-6b-004/006), warm-vs-cold
  ErrReplayIntegrity verdict taxonomy, CO-017 cross-version fold fence.
- Record-round grounding (finding 0 of formal/REPORT.md): a record
  round is a replacement listing, so a partition holding rows no
  completed round published is cleared before the round's first write
  (groundRecordScope + engine ClearSourceCacheScope), witnessed by
  TestChaosSourceCacheRecordFlipOverReplayDebris.
- CO-6b-009: session persistence semantics pinned contractually;
  wholesale resume-clear rejected as unsound.
- Test-only commit-order trace recorder (sync_trace_audit.go) and the
  chaos trace oracle exporting the JSONL fixtures judged by the Occult
  trace bridge (formal/occult/TRACE_BRIDGE.md).
- Source-cache chaos suites wired into chaos-check; verification
  packet under docs/verification/sync-replay-6b/; RFC 0010.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kans
kans force-pushed the kans/phase-6b-replay-orchestration branch from 909375d to e06c2b9 Compare September 1, 2026 19:40
Comment on lines +634 to +643
if o.s.state.SourceCacheReplayed(o.rowKind, o.scopeKey) {
// Duplicate page / lost-response retry: the copy already ran this
// sync. Replay is replacement, so re-running it would also wipe
// overlay rows upserted since. Skip the copy; apply the page's
// upserts/tombstones normally.
l.Debug("source-cache replay already completed for scope this sync; skipping copy",
zap.String("row_kind", string(o.rowKind)),
zap.String("scope_key", o.scopeKey),
)
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (medium confidence): the replay branch never consults sourceCacheScopeGrounded, so the record→replay direction is unguarded while replay→record is. If a record page for scope S runs first this attempt (grounding + row puts, no validator yet) and a later page for the same scope carries SourceCacheReplay, SourceCacheReplayed(S) is still false, so the replacement copy runs and silently discards the record round's fresh rows — a stale-over-fresh blend with no error and no warn, which is exactly the outcome the rest of this file fails loud on. Consider treating "scope already grounded this attempt but not yet replayed" as a cold ErrReplayIntegrity (or at minimum a warn) alongside the existing provenance checks.

// action — even on syncs that end up consuming cold. A capable
// connector on a store without the source-cache surface runs as if the
// capability were absent (replay is Pebble-only by design).
s.sourceCacheStore = nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (low confidence): this resets s.sourceCacheStore per attempt and the teardown resets s.sourceCacheWarm, but s.sourceCacheScopeGrounded and s.sourceCacheScopeLocks are never cleared. Sync elsewhere explicitly contemplates "a reused syncer" (the ClearIngestInvariantVerification comment), and on a second Sync call the stale grounded set would suppress groundRecordScope for every scope grounded by the first sync — skipping exactly the debris clear CO-6b-008 added. No in-tree caller reuses a syncer today, so this is latent; resetting both maps here (s.sourceCacheScopeGrounded = native_sync.Map{} etc.) makes the per-attempt reset symmetric with the two fields that already are.

Comment thread pkg/sync/state.go
Comment on lines +69 to +72
RecordSourceCacheHit(rowKind sourcecache.RowKind, scopeKey string, cacheValidator string)
SourceCacheHitValidator(rowKind sourcecache.RowKind, scopeKey string) (string, bool)
MarkSourceCacheReplayed(rowKind sourcecache.RowKind, scopeKey string)
SourceCacheReplayed(rowKind sourcecache.RowKind, scopeKey string) bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion (compatibility): State is exported, so adding four methods breaks any downstream implementation at compile time. pushAction a few hundred lines below exists specifically to avoid "changing the exported State interface", so this deviates from the file's own precedent. dotc1z.SourceCacheStore likewise gains three methods (ClearSourceCacheScope, PutSourceCacheCompat, GetSourceCacheCompat), and pkg/sdk/version.go is unchanged at v0.27.0. There are no in-tree external implementers of either interface, so practical risk is low — but per the repo's SDK criteria, either carry the new provenance accessors on the concrete *state (as pushAction does) or land the interface widening with a 0.x minor bump as the compatibility signal.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

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