Skip to content

Take the account event feed into a durable ledger - #729

Merged
jorgemanrubia merged 49 commits into
mainfrom
connector-intake
Sep 17, 2026
Merged

jorgemanrubia merged 49 commits into
mainfrom
connector-intake

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026

Copy link
Copy Markdown
Member

basecamp connect has no intake yet: nothing in the CLI can hold the account event feed, remember what it has seen across a restart, or recover what it missed. The Ruby connector it replaces keeps its dedupe in memory and resumes by forgetting history, so a crash loses every event that landed while it was down.

Tracked in 14 basecamp connect intake: feed, ledger, checkpoint.

Why

Intake is the only work on the feed's delivery path, and the ledger and checkpoint are what turn a crash into a delay. Admission (card 15) and dispatch build on this; neither can start without a durable record of what was seen.

What changes

A new internal/connector package, with no command surface of its own yet — basecamp connect lands with setup (card 16):

  • Intake writes each feed pointer to the ledger if its id is new and hands the id to a queue. It reads no recording and filters nothing; events are ids only, and column or list filtering costs a re-fetch that belongs to admission.
  • SQLite ledger: events (dedupe across both lanes and restarts, tombstones kept indefinitely), the feed position per filter digest, the last poll-served id tracked apart from both, overflow losses, and 410 gaps.
  • Re-entry: a restart resumes from the stored position. A position refused before the connection has served anything re-enters at the ledger's last poll-served id, not the present. A filter change re-enters at that id too.
  • Recovery: an overflow is written to disk before it is accepted. The repair walk runs from one below the lowest missing id, on its own cursor rather than the feed's, and repeats every sixty seconds for ten minutes before calling what is left unrecovered. Open losses resume on start.
  • Backlog warns at 1,000 and stops consuming the feed at 10,000, which also stops the checkpoint.
  • Membership: the project list is refreshed every ten minutes. A change, or an event from a project the live subscription doesn't hold, reconnects the feed.
  • A lock keyed on account and agent Person id, NDJSON pointer lines, and 130/143 exit codes.

Three things in this feed look like "nothing more to read" and aren't: an empty page, a missing next, and a quiet inbox. Each has handling and a test that fails without it.

The two 410s

They mean different things and do not share a recovery path. Both are now told apart below this package, by the SDK's live seam from Event feed: split the poll lanes' 410s and give their 400 a reason:

  • The feed's 410 (FeedPositionGoneError, epoch required) becomes the feed-gap signal, and its resume is followed as served. A resume that does not re-enter at the declared fence is refused. In the repair walk it condemns only the ids behind the epoch.
  • The inbox's 410 (InboxPositionGoneError, no epoch, since=0) is refused on the account lane. It ends the feed rather than being flattened to "epoch 0". A 410 that omits the epoch is never typed with a fabricated one.

A 400's reason decides between recovering and stopping. A reason the contract doesn't name is surfaced, not guessed.

Tests at the real eventfeed.NewLive seam pin this contract, and each one fails against a scratch copy of the SDK with the handling removed.

Built on the SDK's live seams

This PR started against the seam interfaces with a temporary local adapter, because card 09's SDK work was still open. That work has landed: Event feed connector: the inbox lane (4/4), Event feed connector: bind the seams to the generated operations (5/5) and 912. The adapter is deleted, and intake binds to eventfeed.NewLive through LiveOptions.

The SDK's seams own the redirect guard, the 410 and 400 mapping, the one-cursor rule for followed URLs, and cancellation pass-through. One check stays in intake: the repair walk follows next and resume URLs outside the SDK connector, whose same-origin validation is unexported, so the walk checks the origin itself.

SDK pin and MCP

  • SDK: pinned to the released go/v0.19.0 tag (6abe227), which carries 897, 899 and 912. The tag is one commit past the pseudo-version main used; that commit changes only version strings, and the vendored model files are identical.
  • MCP: this PR changes no code under internal/mcpserver and registers nothing. Serve the account event feed over MCP serves the feed operations, and Give cursor-paginated operations no page parameter fixed their catalogue entries. The only files here are the provenance record and the Nix vendorHash.
  • Shared line writer: the stdout pointer line and admission's verdict line now share one writer, in internal/connector/ndjson, and both strip terminal controls from API text.

Invariants

Four review rounds, Copilot and a separate Opus reviewer, ended in a design pass over the package as one state machine: 25 invariants across positions, ledger, recovery, error classification, lifecycle, queue, lock and confidentiality. The fixes are made against those invariants, and each unheld one has a test that failed before its fix. Two points shape the code:

  • Policy lives at the seam, not in each walker. The SDK's live seams check cursors and redirects for both the feed and the repair walk. The repair walk logs only a failure's kind, never its text.
  • Re-entry after a refused position has to work around state the SDK keeps in memory. The connection is ended and remade from the ledger: this filter set's own poll-served id, or the beginning of served history, never the present. A seedable reset cursor in eventfeed would remove that workaround.

New dependency: modernc.org/sqlite, pure Go, so all five release targets still build without cgo.

Not in this PR

The card's done-when needs the running process: 24 hours in --shadow against production, kill and restart against the real feed, reconnect timings. That waits on the command.

The kill-and-restart case, catch-up, streaming, reconnects and shutdown are covered end to end on the SDK's feedtest fakes. The membership refresh is wired against an interface; the SDK-backed project lister comes with the command. A 1,000-event burst goes through intake in about 20ms.

Copilot AI balanced review requested due to automatic review settings September 16, 2026 12:49
@github-actions github-actions Bot added commands CLI command implementations sdk SDK wrapper and provenance tests Tests (unit and e2e) deps labels Sep 16, 2026

Copilot AI 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.

🟡 Changes recommended

Durable reset recovery, lifecycle handling, queue closure, and MCP feed error preservation contain blocking defects.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds durable event-feed intake for the forthcoming basecamp connect, including SQLite-backed deduplication, checkpoints, overflow recovery, and lifecycle support.

Changes:

  • Adds connector intake, queues, locking, recovery, and durable ledger storage.
  • Adds SDK-backed feed adapters with extensive tests.
  • Updates the SDK pin and MCP catalog for event-feed operations.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
go.mod Updates SDK and adds SQLite dependencies.
go.sum Records updated dependency checksums.
nix/package.nix Refreshes the Nix vendor hash.
internal/version/sdk-provenance.json Records the new SDK revision.
internal/mcpserver/model/PROVENANCE.json Updates model provenance.
internal/mcpserver/model/behavior-model.json Adds event-feed operation behavior.
internal/mcpserver/model/openapi.json Adds event-feed API schemas.
internal/mcpserver/domains.go Registers the event-feed MCP domain.
internal/mcpserver/testdata/catalog_snapshot.txt Updates the MCP catalog snapshot.
internal/mcpserver/catalog_test.go Updates catalog expectations.
internal/mcpserver/server_test.go Updates MCP tool-count coverage.
internal/commands/mcp_test.go Updates command-level MCP expectations.
internal/connector/doc.go Documents connector intake semantics.
internal/connector/feed_adapter.go Adapts generated SDK feed operations.
internal/connector/feed_adapter_test.go Tests adapter behavior and errors.
internal/connector/intake.go Implements feed intake orchestration.
internal/connector/intake_test.go Tests intake and recovery signals.
internal/connector/intake_feed_test.go Tests feed integration and restarts.
internal/connector/ledger.go Defines the SQLite ledger schema.
internal/connector/ledger_events.go Implements event persistence and state.
internal/connector/ledger_checkpoint.go Implements durable checkpoints.
internal/connector/ledger_recovery.go Persists losses and feed gaps.
internal/connector/ledger_test.go Tests ledger behavior.
internal/connector/lock.go Adds per-account agent locking.
internal/connector/lock_test.go Tests lock identity and release.
internal/connector/queue.go Adds backlog-aware intake queues.
internal/connector/queue_test.go Tests queue thresholds and cancellation.
internal/connector/repair.go Implements overflow repair walks.
internal/connector/repair_test.go Tests repair and reconciliation.
internal/connector/shutdown.go Adds signal handling and exit codes.
internal/connector/shutdown_test.go Tests signal-to-exit-code mapping.
Review details
  • Files reviewed: 30/31 changed files
  • Comments generated: 10
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/queue.go Outdated
Comment thread internal/mcpserver/domains.go Outdated
Comment thread go.mod Outdated
Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/intake.go
Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/ledger_events.go
Copilot AI review requested due to automatic review settings September 16, 2026 13:04
Comment thread internal/connector/intake.go Fixed

Copilot AI 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.

🟡 Changes recommended

Redirect handling, cancellation classification, ledger permissions, and concurrent pause tracking have unresolved correctness and security issues.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

internal/connector/feed_adapter.go:243

  • This classifier has no context, so caller-driven context.Canceled/DeadlineExceeded errors are wrapped as PollTransient. The event-feed seam requires connector cancellation to pass through unchanged; otherwise reconnect and shutdown enter transport-retry handling. Pass ctx into the classifier and return cancellation directly only when ctx.Err() is set, preserving client timeouts as transient.

This issue also appears on line 305 of the same file.
internal/connector/queue.go:74

  • paused is a boolean even though intake and every open repair walk can call Offer concurrently. If two offers block on a full queue, the first one to resume stores false and fires OnResume while the other (possibly the feed) is still blocked, so Paused() and the operator signal become incorrect. Track the number of blocked offers and emit pause/resume only on the 0→1 and 1→0 edges.
    internal/connector/intake.go:27
  • At a 60-second cadence, a ten-minute window contains about ten repair intervals, not twenty. Correcting this avoids documenting twice the recovery attempts the implementation and tests provide.

internal/connector/feed_adapter.go:309

  • The mint classifier similarly turns connector cancellation into MintTransient. Pass the call context into mintError and return caller-driven cancellation unchanged, while continuing to classify a client-owned timeout as transient.
// mintError classifies a failed CreateStreamTicket call.
func mintError(err error) error {
	if err == nil {
		return nil
	}
  • Files reviewed: 31/32 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread internal/connector/feed_adapter.go Outdated
Comment thread internal/connector/ledger.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 13:22
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the four comments suppressed in the latest review, all fixed in c43c506:

  • Cancellation classification (feed_adapter.go poll and mint classifiers): both now take the call's context. A failure that is the caller's cancellation passes through unchanged; a client-owned timeout with the context still live stays transient. TestCallerCancellationPassesThroughTheAdapterUnchanged fails on 893c0d6.
  • Concurrent pause tracking (queue.go): the flag is now a count of blocked offers, and pause/resume fire only on the 0→1 and 1→0 edges. TestQueuePauseTracksEveryBlockedOffer fails on 893c0d6: the first waiter to resume cleared the flag while the other was still blocked.
  • Repair cadence doc (intake.go): corrected to about ten repair polls in the window, not twenty.

Comment thread internal/connector/intake.go Fixed

Copilot AI 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.

🟡 Changes recommended

Unresolved redirect-data exposure, cursor validation, recovery, locking, and queue concurrency issues can cause leaks or missed events.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/connector/queue.go:124

  • The depth sample and warned transition are not serialized with other noteDepth calls. A sender can sample a warning-level depth, then a receiver drains and observes warned == false, after which the sender sets it true; the queue is now below threshold but recovery never fires unless another operation occurs. Serialize the warning-state transitions and callbacks so observations cannot be applied out of order.

internal/connector/feed_adapter.go:367

  • As in the poll path, the redirect refusal is a url.Error containing the server-chosen target. Preserving it here can render that target in terminal logs; return only ErrRedirectRefused so the mint failure cannot disclose redirect data.
	if errors.Is(err, ErrRedirectRefused) {
		return &eventfeed.MintError{Kind: eventfeed.MintUnrecoverable, Err: err}
  • Files reviewed: 33/34 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread internal/connector/feed_adapter.go Outdated
Comment thread internal/connector/feed_adapter.go Outdated
Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/lock.go Outdated
Comment thread internal/connector/repair.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 13:50
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the two suppressed comments in the last review, both fixed in 18af1cc:

  • queue.go warning edges: the depth sample and the transition are now under one mutex, so an offer cannot raise a warning that a concurrent take has already cleared. TestInvariantF2WarningEdgesSettleOnTheTrueState is race-detector red without it.
  • feed_adapter.go mint redirect: covered by the same change as the poll path — the mint error carries only the sentinel.

This head follows a design pass over internal/connector as one state machine, prompted by three rounds of findings in the same areas. It named 25 invariants; 18 held, 7 did not, and the unheld ones are the findings above. The structural part: the repair walk was a second feed walker with its own copies of cursor validation, error rendering and 410 classification, and those copies were where the repeat findings came from. The adapter is now the only policy boundary, and the repair walk carries explicit per-pass state instead of inferring it from its cursor's shape.

One thing this PR cannot fix: re-entry after a refused position depends on the package's in-memory reset cursor, which a restart cannot seed. The connector ends the connection and remakes it from the ledger, which works but has to fail closed by hand at each branch. A seed for that cursor, or a disposition on PositionRejected, belongs in basecamp-sdk's eventfeed package.

Copilot AI 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.

🟡 Changes recommended

Rejected empty-page checkpoints can skip events, and the MCP domain currently exposes a replayable stream credential.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 34/35 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/ledger_events.go Outdated
Comment thread internal/mcpserver/domains.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 14:08
@github-actions github-actions Bot removed the commands CLI command implementations label Sep 16, 2026

Copilot AI 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.

🟡 Changes recommended

Queue notifications can be reordered, recovery can report false success, and pointer timestamps lose precision.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 32/33 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread internal/connector/intake.go Outdated
Comment thread internal/connector/queue.go Outdated
Comment thread internal/connector/repair.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 14:19

Copilot AI 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.

🟡 Changes recommended

Text timestamp ordering can retain expired ledger content, and one test file violates enforced import formatting.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/connector/round4_test.go:9

  • Remove the blank line splitting the standard-library imports. The repository requires standard-library imports to remain in one alphabetized group (STYLE.md:52-59), and goimports will rewrite this file, causing the formatting check to fail.
  • Files reviewed: 33/34 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_events.go Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 14:29
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the comment suppressed in the last review (round4_test.go:9, a standard-library import group split by a blank line): fixed in f728b71. It was the only file in the package with that problem. make check passed because its format step runs gofmt, which accepts a blank line inside a group, and none of the enabled linters check import grouping. So STYLE.md's grouping rule is currently enforced only in review.

Copilot AI 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.

🟡 Changes recommended

Ledger path validation remains vulnerable to redirection, and repair failures can strand committed events without handing them to admission.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 44/45 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread internal/connector/ledger.go Outdated
Comment thread internal/connector/intake.go
Two findings.

The ledger holds feed positions, which resume the account's feed, so it is a
credential file — but it was vetted by name: os.Stat on the immediate
directory only, following an existing symlink, with nothing said about the
ancestors. A group-writable ancestor is enough to rename the checked 0700
directory away and put another in its place between the check and SQLite's
open. It now goes through the same private-path check as the instance lock
and the trust file: every ancestor owned by this user and unwritable by
others, the file opened without following symlinks and inspected through
that descriptor, and a file another user can so much as read refused. Its
own extra rule stays, because SQLite writes -wal and -shm beside it: the
directory must be 0700, not merely unwritable.

And a handover that failed after the commit waited for a restart. That is a
long wait for a connector that runs for weeks, and the repair walk is where
it bites: the commit resolves the loss's missing id, so the reconciliation
that follows sees nothing missing and closes, while the event was recorded,
never judged and never mentioned again. Every commit already goes through
one handover path; now a failure on it is remembered there, and the sweep
that re-offers open losses re-offers those ids too. A crash still loses the
list, and loses nothing with it: the next start offers every record still
in seen, and clears what it has offered.
Comment thread internal/connector/setup/private_state.go Fixed

Copilot AI 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.

🔵 Needs a closer look

The new concurrent, persistent recovery state machine and security-sensitive filesystem behavior warrant final human validation despite extensive tests.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/connector/lock.go:26

  • Use “a” before “flock”; “an flock” is grammatically incorrect.
  • Files reviewed: 44/45 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

A writable handle was closed in a defer, so a close that failed said
nothing and the caller went on to treat the file as one that exists. It is
closed and reported explicitly now, and since what is durable about an
empty file is its directory entry, the directory is synced before the
create is called done. The other write in the package already syncs, closes
and renames; SQLite answers for the ledger's own durability.

And the start's re-queue cleared the whole stranded list, on the assumption
that it had just offered everything in it. It had not necessarily: the
repair walks start before the re-queue and serve older ids, so one stranded
mid-pass — past the page the walk is on — was forgotten without ever being
handed over, which is the wait this was meant to remove. It now clears only
the ids it carried in.
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Head is f022c1a. Three changes since 20eba38, plus the two before it answered on their threads.

CodeQL alert 323 (private_state.go:121) — answered on the thread and fixed. The handle is mine, not #731's; every other write path in this PR was checked rather than just the flagged line.

The start's re-queue cleared too much — found by my adversarial reviewer on 20eba38, and it was a real hole in the fix I had just made. requeueSeen cleared the whole stranded list on the assumption it had offered everything in it. It had not necessarily: repair walks start before the re-queue and serve older ids, so an id stranded mid-pass, past the page the walk was on, was forgotten without ever being handed over — exactly the wait the stranded list exists to remove. It now snapshots what it carried in and clears only that. TestTheStartOnlyForgetsTheStrandedIDsItCarriedIn (an id stranded from inside a queue callback while the re-queue is paging) fails on 20eba38 and passes here.

The "an flock" nit (suppressed, lock.go:26) — fixed.

One note from the same review, recorded rather than changed: sweepStranded and sweepLosses share a goroutine, so a hand-off waiting at the pause threshold also holds up re-offering open losses. That is the right way round — the backlog is full, the pipeline is stopped, and starting more repair walks would only make it worse — and the comment now says so.

On the "needs a closer look" verdict: agreed, and it is not something I can close from here. The PR is a concurrent, durable state machine touching credential-bearing files, and it wants a human read. make check is green (race suite, lint, drift, coverage), every review thread is answered and resolved, and my adversarial reviewer (a separate Opus agent, run on each exact head — not Copilot, and not a human) has converged on this shape with the last two rounds' findings taken.

Copilot AI 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.

🟡 Changes recommended

Documented terminal ledger states can currently be transitioned back into active work states.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 44/45 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/ledger_events.go Outdated
completed and discarded are documented as the end of a record's life, but
SetState updated the row whatever it held, so either could be moved back
into the working states — an event dispatched a second time, or, once
DropContent has taken the payload and left the tombstone, a row picked up
as work with nothing in it.

The lifecycle is now a table of edges rather than a sentence in a comment,
and the refusal is in the UPDATE itself: a state is written only where the
record is in a state that may enter it, so a check and a write cannot race.
Writing the state a record already has stays allowed — that is a repeat,
not a move, so a retry after a crash is not an error. A refused transition
is told apart from a missing row.

Migration 3 adds the same refusal as a trigger, for the two edges that
matter most: whatever ever writes to this file, a terminal record cannot
change state.

Copilot AI 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.

🟡 Changes recommended

Repair can restore a stale pre-epoch cursor after a transient failure following a 410 resume.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 44/45 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/repair.go
A 410 with an epoch says the history below it is gone for good, and the
walk clears its cursor on disk when it takes the fence. But the pass kept
its own copy of the position in a local, and returned that copy if the
resumed poll then failed for any other reason — a connection reset was
enough. The caller put it back in memory, and every later pass entered at
a position the feed had already refused forever.

The walk's memory of the cursor is now the loss's own field, kept equal to
what the ledger holds at every point: saved first, remembered second, and
whatever a failure wrote is what the pass returns.

Two edges the lifecycle was missing, from the same spec the table came
from: seen to queued, because admission commits an admitted verdict AS
queued when the conversation is already live, and dispatched back to
admitted, because a dispatched record whose worker never started has its
exposure withdrawn. Without them the next card writes around SetState.

And a repeat no longer touches updated_at, which is the retention clock: a
re-written completion was silently restarting the window.
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Head is 9c89aca. Two rounds since f022c1a, both answered on their threads.

Terminal means terminal (3963d28). SetState updated the row whatever it held, so completed and discarded could move back into the working states — an event dispatched twice, or a DropContent tombstone picked up as work with an empty payload. The lifecycle is now a table of edges, enforced in the UPDATE itself (... AND state IN (<states that may enter this one>), so a check and a write cannot race), plus a BEFORE UPDATE OF state trigger in migration 3 for the two edges that matter whatever writes the file. Twelve terminal-to-other pairs proven red, plus a raw-SQL test that goes around SetState.

A pre-epoch cursor could come back (9c89aca). After a 410 the walk clears its cursor on disk, but the pass kept a copy in a local and handed it back if the resumed poll then failed transiently — so every later pass re-entered at a position the feed had refused forever. The local is gone; the walk's memory of the cursor is the loss's own field, kept equal to the ledger's at every point.

Two things my adversarial reviewer found in the same rounds, both taken:

  • The lifecycle was missing two edges the spec already names: seenqueued (admission commits an admitted verdict AS queued when the conversation is live, per admission/commit.go's contract on main) and dispatchedadmitted (a dispatched record whose worker never started has its exposure withdrawn). Without them card 15 or the dispatch card would have had to write state around SetState.
  • A repeat of the same state was refreshing updated_at, which is the retention clock — a re-written completion silently restarted the window. A repeat now leaves it alone.

The invariant list gained two entries: A2, a cursor older than the feed's current epoch is never usable; E4, terminal states have no outgoing edges, and the allowed transitions are the table in ledger_events.go.

make check is green. Every thread is answered and resolved.

Copilot AI 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.

🟡 Changes recommended

The admission line writer has a normal-path data race during lazy initialization.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 44/45 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/admission/run.go Outdated
The line writer was built on the first verdict. Nothing in this package
needs that: admission knows its sink before it starts, so the writer is
built there and injected, and the struct keeps a writer and nothing else —
no sink, no once, no first-use path for a pool of workers to take together.

A stream whose lines stay whole rests on there being exactly one writer and
one lock per sink. That is easier to keep true when there is no second
place a writer can come into being.

Copilot AI 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.

🔵 Needs a closer look

An uncapped Retry-After sleep can keep a loss open beyond its declared 24-hour absolute deadline.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/connector/repair.go:115

  • The 24-hour maxLossLifetime is not actually an upper bound while this sleep is in progress. PollThrottled carries the server's uncapped Retry-After, so one response whose delay exceeds the remaining lifetime can leave the loss open—and occupy a repair worker—past the promised absolute deadline (the same uncapped sleep exists in finalPass). Cap the sleep at the time remaining until DetectedAt + maxLossLifetime; waking then should close the loss without polling before the server-directed delay.
  • Files reviewed: 45/46 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

The absolute lifetime bounds the loss, but the waits between its attempts
were unbounded: Retry-After is whatever the server names, so one response
asking for thirty hours held the loss open — and its repair worker with
it — well past the day the connector promises.

Every repair wait is now capped at the time the loss has left. Woken at the
deadline, the next turn around the loop sees it and closes the loss without
polling first, which is the honest answer: the server asked for a wait this
connector no longer has to give.
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Head is 0d84ab6. Two rounds since 9c89aca.

The admission line writer (f427ce7, answered on its thread). I could not reproduce the race and I set out why I think the specific claim does not hold — && short-circuits, so the guard reads l.out only when l.w == nil, and in that configuration once.Do assigns nothing; the two paths are mutually exclusive. My adversarial reviewer checked the argument independently and agrees. I removed the laziness anyway, because the shape is right: the writer is built in Run before any worker exists and injected, and lineWriter now holds a writer and nothing else. TestTheVerdictWriterIsBuiltBeforeAnyWorkerWrites and TestOneSinkHasOneWriter are new; the cross-package TestIntakeAndAdmissionShareOneLineWriter already tears visibly if the two ever get separate locks.

The uncapped Retry-After (suppressed, repair.go:115) — this one was real, and it is fixed in 0d84ab6. maxLossLifetime bounds the loss; the waits between its attempts were bounded by nothing, so a single response asking for thirty hours held the loss open, and its repair worker with it, past the day the connector promises. Every repair wait — the cadence wait in reconcile and the throttle wait in finalPass — is now capped at the time the loss has left. Woken at the deadline, the next turn around the loop closes the loss without polling first: the server asked for a wait this connector no longer has to give. TestAThrottleCannotHoldALossPastItsAbsoluteDeadline (detected, then throttled for six hours with one hour left) fails on f427ce7 with a 6h sleep and a loss 29 hours old, and passes here with the loss closed inside its day.

The invariant list now reads: A2 a cursor older than the feed's current epoch is never usable; C, a loss is scheduled or closed, and no wait may carry it past its absolute deadline; E4 terminal states have no outgoing edges; H, exactly one NDJSON writer per sink, constructed once and injected.

make check green, every thread resolved.

Copilot AI 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.

🟡 Changes recommended

A panicking pause callback leaves the queue’s depth and paused state permanently incorrect.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 45/46 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/connector/queue.go
A callback belongs to whoever built the queue, and one that panicked took
the queue's own state with it: the pause callback runs before the offer's
cleanup was in place, so the id's count and the wait it announced both
stayed behind — a queue reporting an item nobody can take, paused with
nothing able to lift it.

Three changes, all of them about the same rule. The state a callback is
told about is committed before it runs, so nothing it does can change it.
An offer that leaves without sending takes its count back on every way out,
not only on cancellation. And a panic in a callback is contained and
logged: a reporting bug is not a reason to lose the feed.

Copilot AI 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.

🔵 Needs a closer look

Account intake must reject inbox-only Reasons filters before repair workers can issue widened requests.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/connector/intake.go:225

  • This only applies syntax validation, but intake always uses the SDK's account lane, where Reasons is explicitly unsupported. eventfeed.New will eventually reject it, but Run starts repair workers before constructing the feed, so an open legacy loss can poll through PollsFor with this invalid filter first (and the adapter may omit the inbox-only dimension, widening the repair read). Reject non-empty Reasons here so invalid configuration fails before any repair or wire work.
  • Files reviewed: 45/46 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Reasons filter the inbox — why an event reached you — and the account feed
does not carry them. The SDK refuses them when the feed is built, but that
is too late: Run starts the repair workers first, so an open loss could
walk under a filter set the lane cannot honor, and a dropped dimension
widens a read rather than narrowing it. Configuration that cannot mean what
it says is refused before any wire work.

And intake now hands the queue its logger, so the callback panic the queue
contains is reported somewhere. A queue built by a caller who did not think
about it would have swallowed it silently.
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Head is 5e448b8.

The panicking pause callback (90e644a, answered on its thread). Real, and the ordering was exactly as described. The offer's cleanup is now registered before the pause is delivered, an offer that leaves without sending takes its count back on every way out through a single settle, and fire recovers around every callback and reports through a logger. TestAPanickingPauseCallbackLeavesNoPhantomBacklog killed the test process on 0d84ab6; now the blocked offer completes, the depth has no phantom item, the pause lifts, and the next crossing is still reported.

The inbox-only Reasons filter (suppressed, intake.go:225) — right, and the timing argument is the part that matters. eventfeed.New would refuse it, but Run starts the repair workers before the feed exists, so an open loss could walk under a filter set the account lane cannot honor — and a dropped dimension widens a read rather than narrowing it. Non-empty Reasons is now refused in New, before any repair or wire work. TestIntakeRefusesAnInboxOnlyFilter is red on 90e644a.

One thing from my adversarial reviewer in the same round: with no logger set, the contained panic was silent, and the queue is built by the caller rather than by intake. Intake now hands the queue its own logger — one owner, one logger — with TestIntakeGivesTheQueueItsLogger covering it.

make check green, every thread resolved, all 31 checks passing.

Copilot AI 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.

🔵 Needs a closer look

The large durability-, concurrency-, security-, and recovery-sensitive state machine warrants final human review despite extensive tests and green checks.

Review details
  • Files reviewed: 45/46 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

The previous commit had intake write the queue's logger field while the
callback path read it unlocked, and a queue can already be live when intake
adopts it — admission takes from the same one. The race detector found it
three times in three on a queue in use during construction.

The logger is now held under the queue's own lock: SetLogger for a caller,
an adopt that checks and sets in one step for intake, and the one read made
with the lock released around the callback it reports on.

Copilot AI 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.

🔵 Needs a closer look

The large concurrent durability state machine warrants final human validation despite its extensive test coverage.

Review details
  • Files reviewed: 45/46 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Head is d13fda8. One commit since 5e448b8, and it fixes a race that commit introduced — found by my adversarial reviewer (a separate Opus agent), not by a review here.

5e448b8 had intake write the queue's logger field while the callback path read it without the lock, and a queue can already be in use when intake takes it over: admission takes from the same queue. The logger is now held under the queue's own lock (SetLogger for a caller, and a check-and-set in one step for intake), and it is read with the lock released around the callback it reports on. TestAdoptingAQueueInUseDoesNotRace builds twenty intakes over a queue whose callbacks are panicking. With the old unlocked code it failed under -race in 3 of 3 runs; with the lock it passed 5 of 5.

Where things stand on d13fda8: all 31 checks pass, make check is green locally, and every review thread is answered and resolved. My reviewer has converged on this exact head. Copilot's latest verdict is "Needs a closer look" with no new comments: it is asking for a human to check the state machine, not reporting a defect. That is for a human to settle, not me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deps sdk SDK wrapper and provenance tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants