Skip to content

feat(data_sync): cursor provenance on run start and adapter input - #116

Closed
maxidragon wants to merge 5 commits into
developfrom
data-sync-cursor-provenance
Closed

feat(data_sync): cursor provenance on run start and adapter input#116
maxidragon wants to merge 5 commits into
developfrom
data-sync-cursor-provenance

Conversation

@maxidragon

Copy link
Copy Markdown
Member

Problem

Pressing Run on the Data Sync dashboard means "continue whatever ran last", and nothing says so.

api/run.ts resolves a start cursor for every non-fullSync start — the shared sync_cursors row, or for an opted-out entity type the last incomplete run's position. That is the intended incremental behaviour and it is invisible: the operator is never told, and the only opt-out is a switch they have to remember to tick.

For a cursor that is purely a position, fine. For a cursor that encodes scope — filters, date/id bounds, dry-run flags — it is not. A fresh, unfiltered run inherits a failed run's date window, walks only that window, and reports completed having skipped everything outside it.

The adapter cannot defend itself, because it cannot tell that apart from a Retry that was deliberately told to resume: retry.ts passes both a cursor and the previous run's parameters, and nothing on the run row separates the two — initialCursor is written by both paths, and no column links a retry to its predecessor. So refusing an inherited cursor would break Retry.

This is a recorded residual risk, not a new discovery. .ai/specs/2026-08-12-data-sync-run-scoped-cursor.md Risk #2 says it outright: "indistinguishable ... without a window fingerprint on the run row." Provenance is a lighter answer to the same problem, and this PR closes that risk.

Solution

Tell the adapter where the cursor came from. Behaviour is unchanged.

CursorOrigin is an optional field on StreamImportInput / StreamExportInput:

Value Meaning
none No cursor. Start from the beginning.
explicit The caller named it — a Retry resuming the previous run's own position, or a provider flow that computed one.
inherited Core resolved it from prior state the caller never named.
self This run's own committed progress, after a queue redelivery.

The origin and its source run are persisted on sync_runs, and the run detail page — which rendered no cursor information at all — now says "continued where an earlier run stopped" with a link, or "continued from the saved incremental position" when it came from the shared row.

Two things worth a reviewer's attention

self is what makes refusing inherited safe. The engine hands the adapter run.cursor, not initialCursor, so once a batch commits the position is the adapter's own output whatever the run started from. A redelivered job would otherwise re-enter the adapter with the run's start-time label still attached, and an adapter refusing inherited would restart from the top on every worker hiccup — the Retry breakage, moved one level down. So the column records where the run started, while the value handed over is derived per delivery from batchesCompleted.

Retry is labelled per case, not wholesale. retry.ts is previous.cursor ?? resolveStartCursor(...), so a retry of a run that never committed a batch inherits exactly like a fresh dashboard start. Labelling every retry explicit would make the discriminator a second thing to distrust; the fallback is labelled inherited.

Backward compatibility

Additive throughout — recorded in BACKWARD_COMPATIBILITY.md:

  • §2 Type interfaces — optional fields only, same shape as the signal?: AbortSignal addition already on develop.
  • §3 Function signatures — unchanged. resolveStartCursor and resolveResumeCursor keep their exact signatures as delegating wrappers.
  • §8 Database schema — two nullable, no-default columns; metadata-only ADD COLUMN, no table rewrite, no backfill. Pre-migration rows read null, which the engine reports as an absent origin rather than a guessed one.

Adapters that ignore the field behave exactly as today.

Testing

yarn typecheck, yarn lint, yarn generate (no diff), yarn agents:check-budget — all clean, run locally.

yarn db:generate reports data_sync: no changes, confirming the hand-written migration and snapshot match the entity change exactly.

data_sync suites: 35 passed, 254 tests (from 32/226). New coverage:

  • lib/__tests__/cursor-origin.test.ts — the derivation truth table, above all batchesCompleted > 0 → 'self'.
  • api/runs/[id]/__tests__/retry-cursor-origin.test.ts — the three retry cases. Retry cursor precedence had no test at all before this, so this covers pre-existing behaviour too.
  • lib/__tests__/sync-engine-cursor-origin.test.ts — the engine wiring, import and export.
  • __integration__/TC-DS-011.spec.ts — the wire contract on both run read APIs.
  • Extended: start-cursor, sync-run-service.shared-cursor, api/run, sync-scheduled, run-detail page.

Two pre-existing failures elsewhere in the repo are unrelated and reproduce identically on a clean develop: 10 @open-mercato/documents suites (Cannot find module '#generated/entities.ids.generated') and one staff timesheets test. Both verified against origin/develop directly.

Review notes

  • The spec is the first commit, so it can be read and ruled on before the diff.
  • data_sync/AGENTS.md asks to check before changing cursor semantics — semantics are unchanged, but the adapter contract grew, so that is the call being requested here.
  • Out of scope, as the original request stated: the start-form hint ("this will continue the last incomplete run — [start from the beginning]").

maxidragon and others added 5 commits August 31, 2026 17:39
A run reaches an adapter carrying a cursor and no indication of where it
came from. A fresh dashboard start that silently inherited a previous
run's position is indistinguishable from a Retry that was told to resume.
For an adapter whose cursor encodes scope rather than only a position,
that difference decides whether the run is correct: an inherited cursor
imposes a stranger run's window and the run reports completed having
skipped everything outside it.

Specs cursorOrigin ('none' | 'explicit' | 'inherited' | 'self') on the
adapter input, persisted on sync_runs alongside the source run id, and
surfaced on the run detail page. Behaviour is unchanged.

The fourth value is load-bearing: the engine hands the adapter
run.cursor, not initialCursor, so on a queue redelivery a row stamped
'inherited' at creation would make a refusing adapter reject its own
mid-run resume. 'self' is derived from batchesCompleted, so the column
records where the run started and the delivered value describes what is
actually being handed over.

Closes the Risk #2 residual risk recorded in the run-scoped cursor spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run reached an adapter with a cursor and nothing to say where it came
from, so a fresh start that silently resumed the last incomplete run was
indistinguishable from a Retry told to resume. Adapters whose cursor
encodes scope had no basis to refuse the first without breaking the
second.

Adds CursorOrigin ('none' | 'explicit' | 'inherited' | 'self') as an
optional field on StreamImportInput/StreamExportInput, two nullable
columns on sync_runs recording the start-time origin and its source run,
and resolveStartCursorWithOrigin / resolveResumeCursorWithSource. The
previous resolvers stay as delegating wrappers, so no signature changes.

The engine derives what it is actually handing over rather than replaying
the stored label: once batchesCompleted > 0 the cursor is the adapter's
own output, so a redelivery reports 'self'. Without that, an adapter
refusing 'inherited' would reject its own mid-run resume.

Retry is labelled per case, not wholesale. Resuming the previous run's
own position is 'explicit', but retrying a run that never committed a
batch falls through to the same inherited resolution a dashboard start
uses, and is labelled 'inherited' accordingly.

Behaviour is unchanged; adapters ignoring the field see what they see
today, as do runs written before the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run detail page rendered neither cursor nor initial cursor, so an
operator who started a run and watched it process a fraction of the table
had no way to find out why. Cursor provenance makes that answerable, so
answer it.

Both read routes now return cursorOrigin and cursorSourceRunId, and the
detail page renders one line for an inherited start: "continued where an
earlier run stopped" with a link to that run, or "continued from the
saved incremental position" when it came from the shared cursor row and
there is no run to point at.

Stays quiet for 'none', 'explicit' and 'self' — a note shown on every run
is one every operator learns to skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d retry

Pins the derivation that makes the discriminator safe to act on: once a
run has committed a batch the engine reports 'self', so an adapter that
refuses an inherited cursor does not refuse its own redelivered resume.

Also covers the retry cursor precedence, which had no test at all before
this change — including the fallback where a retry of a run that never
committed a batch inherits like a fresh start.

Adds TC-DS-011 for the wire contract on both run read APIs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cation

Adds the framework-docs section an adapter author needs to act on the new
field, including why 'self' makes refusing an inherited cursor safe, and
the AGENTS.md rules for start paths that must label what they resolved.

Records the change in BACKWARD_COMPATIBILITY.md as additive under the
type-interface, function-signature and database-schema categories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxidragon maxidragon added feature New feature review Ready for code review priority-medium Normal product priority risk-high High regression or blast-radius risk needs-qa Requires manual QA before merge labels Aug 31, 2026
@jtomaszewski

Copy link
Copy Markdown

Should I review this or will you still redo it (after our today's call)?

@maxidragon

Copy link
Copy Markdown
Member Author

Nope, closing this for now.

@maxidragon maxidragon closed this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature needs-qa Requires manual QA before merge priority-medium Normal product priority review Ready for code review risk-high High regression or blast-radius risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants