Skip to content

Event feed connector: foundations (1/3) - #777

Merged
jeremy merged 61 commits into
mainfrom
event-feed-foundations
Sep 16, 2026
Merged

jeremy merged 61 commits into
mainfrom
event-feed-foundations

Conversation

@jeremy

@jeremy jeremy commented Aug 18, 2026

Copy link
Copy Markdown
Member

First half of the SPEC.md §23 event feed connector, split out of #705 so the
state machine can be reviewed on its own. #705 keeps the run loop, catch-up,
recovery and the tier-2 driver, and now stacks on this.

Eight bot rounds on #705 did not converge (12→3→5→2→2→1→3 threads, with late
findings in files no earlier round had touched), and a review pass found a P1
credential defect that all eight missed because it composes two files across a
package boundary. Splitting is the response to that shape.

Size: ~2.7k lines of production code, ~7k with tests.

What is here

Everything the run loop is built from and nothing that runs — each piece
testable without starting a feed:

seams.go TicketMinter / PollSource / CableTransport / CableConn
event.go Event, Cursor, Page, Signal, Disposition, Observer
errors.go TerminalError and its reason codes
filters.go, digest.go the filter set, its fail-closed validation, the srv1 key
checkpoint.go CheckpointKey / FlatKey / CanonicalOrigin, the store seam
continuation.go the §8 same-origin validation algorithm
filestore.go the built-in FileCheckpointStore
dedupe.go, backoff.go, clock.go, cable.go LRU, reconnect schedule, timers, frame codec
transport.go, websocket_transport.go cable-URL policy, the default transport
feedtest/ host-supplied fakes for all four seams

There is no consumer entry point yet: New and the loop are on #705.

The four fixes

1. The cable dial takes no credential it was not given (P1)

The cable origin is chosen by the server — the mint returns a url and the
connector dials it verbatim, cross-host by design — and the short-lived ticket
in its query is the only credential that origin is entitled to. Two paths handed
it more.

WebSocketTransport.HTTPClient, or http.DefaultClient when nil. An
*http.Client carries three credentials invisible at the call site: a
RoundTripper may inject Authorization, a Jar attaches cookies, a
TLSClientConfig may present a client certificate. The DefaultClient fallback
is the same hazard with no call site at all. Deleted rather than validated
a RoundTripper is opaque, so no runtime inspection could accept one client and
refuse another. Handshakes now run on a package-owned client. (The field had no
callers anywhere, so nothing regressed with it.)

URL userinfo. net/http's send() turns it into a Basic Authorization
header, so a mint whose url carried userinfo made the connector authenticate
to a server-nominated origin with a credential the server chose. Refused before
any network I/O.

The proxy. A wss:// handshake reaches a proxy as CONNECT host:port, so
the ticket stays inside the tunnel; a ws:// handshake is forwarded in absolute
form, putting /cable?ticket=… in the proxy's request line and access log in
the clear. Reachable, not theoretical: §9 admits ws:// for *.localhost, and
net/http's proxy rules exempt the literal localhost and loopback IPs but
not .localhost subdomains. Cleartext dials no longer proxy; TLS dials
still do.

Pre-fix transcript, against un-fixed code:

handshake 0 sent Authorization: "Bearer caller-bearer-token"
handshake 0 sent Proxy-Authorization: "Basic cHJveHk6c2VjcmV0"
handshake 0 sent Cookie: "session=caller-session-cookie"
the server accepted 1 connection(s); the refusal must precede all network I/O

and from the proxy sentinel with the cleartext exemption removed:

a cleartext cable dial reached the proxy 1 time(s):
  ["GET http://app.localhost:9/cable?ticket=sekrit-ticket-value"]

TestCableHTTPClient_IsWiredShut exists because the first mutant written
against the proxy fix survived
: Proxy: proxyFromEnvironment captures the
var's value at init, so the behavior tests' sentinel never reached it — meaning
both would pass a regression to Proxy: http.ProxyFromEnvironment. The wiring
assertion holds the shape they cannot observe.

2. A suppressed duplicate is not a delivery

§23 defines the LRU as "actually-delivered event ids", recorded by every
delivery. Seen refreshed recency on a hit, which is the case where the
event is suppressed and no delivery happens. §23 says to expect poll-vs-push
duplication continuously, so a hot id was pinned at the front and evicted ids
delivered once and never seen again — which become eligible for exactly the
re-delivery the LRU prevents.

TestDedupe_HitRefreshesRecency is inverted to ..._HitDoesNotRefreshRecency,
and I am calling that out rather than letting it look like a test edited to
accept a fix.
It asserted the negation of the contract; nothing short of
inverting it is honest.

3. The checkpoint store reads a bounded regular file, under one lock

#761: the lock registry was keyed on the exact path spelling. On APFS or
NTFS feed.json and Feed.json are one file, so two stores took two mutexes —
the lost update the registry exists to prevent, reached by two call sites
disagreeing about capitalization. The lock key is now case-folded; the path each
store reads and writes is not.

The read followed whatever the path named. Against the pre-fix read, all four
cases fail:

directory       ... reported "is a directory", not a store-type refusal
FIFO            ... did not return (open blocks until a writer appears)
/dev/zero       ... did not return (reads without end)
8 MiB + 1 file  ... read whole, then failed on a JSON parse error

The FIFO and device cases are hangs, so every assertion runs under a bound. Not
defensive dressing: the first draft bounded only the FIFO, and /dev/zero took
the package's 45s timeout with it, naming nothing.

4. One observer-safe URL redactor

The primitive #705 applies to every URL-bearing observer surface. Reduction is
via CanonicalOrigin rather than truncation at ?, which matters for the case
a naive redactor misses: userinfo is a credential in the authority, so
https://attacker:hunter2@evil.example/steal?ticket=… survives query-stripping
intact and does not survive this.

Verification

Pristine worktree, one pass, clean tree before and after:

  • go build / go vet / go test -race -count=1 / -count=5 — all pass
  • make go-lint — 0 issues
  • gosec -severity high -exclude-dir=pkg/generated on the CI-pinned v2.23.0
    (module hash verified, not a scratchpad binary) — 0 issues
  • full make checkexit 0

Every fix was red-proven before it was written, and every test mutation-checked
after. Three tests were rewritten because mutation showed them vacuous.

One preparatory commit

1646f2c1f relocated the run-coupled declarations out of checkpoint.go and
continuation.go so the two halves fall on file boundaries. The moved function
bodies are byte-identical. It also added direct tests for checkContinuation
and Filters.clone, both of which were only reachable through a full run.

Development history, review threads and proof lineage for every file here are on
#705, preserved at tag pre-split/705-head.

Five more from review

Copilot's rounds on this branch found five further defects; all five are fixed, each
red-proven against the un-fixed code first.

6c6e14f16 A null type is not a broadcast. A *string gives the same nil for an absent key and a JSON null, so {"type":null} was liveness-only while {"type":null,"identifier":…,"message":…} was delivered as an event — one wire value in two classes depending on its siblings. Presence is now decoded separately. A present-but-null type takes the ignore branch, not the reject branch: it names no type to recognize, which is §23's unrecognized-type case. BC3's push lane sends {identifier, message} with no type key at all, checked against the current head of bc3 #9659, so the narrowing drops nothing real.
fae998b16 Close bounds the read, not just itself. closeGraceBudget stopped Close from waiting out the close handshake, but the socket is what releases a parked read and coder/websocket does not tear it down until its own 5s+5s ends — so a pending ReadFrame stayed blocked four seconds after Close returned. Worse, a background read was uncancellable: the library installs its cancellation hook only when the read context has a Done channel, and ReadFrame(context.Background()) is how a run loop parks a pump. The connection now owns a lifetime context every read and write derives from, cancelled once Close is done waiting — after the budget, never before, so the close frame is still written.
60a28700d The package docs described the finished connector. There is no Connector, no constructor and no run loop here, and the docs said "runs the whole protocol". Now: foundations only, what has landed, both pending pieces, and that everything below documents the architecture they implement. AGENTS.md's row carried the same overclaim.
791143207 The store's write is bound like its read. The read refuses a file past 8 MiB; the write did not, so a Save crossing the cap renamed into place a file nothing can read again — and since Save reads before it writes, Save too. No in-band recovery; the operator must delete the file, discarding every other lineage's cursor. Reaching the cap is accretion, not an adversary: there is no delete, so a filter change leaves the old lineage in the file forever. Refusing degrades to the documented failed-save outcome instead of an unrecoverable one.
b15e8d031 Cancellation outranks a local close on entry too. ReadFrame documents the precedence and honored it on the way out but not on the way in, so a cancelled read over a closed connection reported a connection failure — the shutdown a run loop performs. WriteFrame already checked the context first; the two disagreed. The assertion went in the shared transport contract, where the feedtest fake already passed it and the real transport did not.

Two findings were declined on merit with the reasoning in a comment rather than left open, and a third — the symlinked store path versus atomic rename — is flagged for a human call: it is the third round on one file, and every candidate remedy trades away a different documented property of what a store file's identity is.


Summary by cubic

Adds the foundations for SPEC §23’s Go event-feed connector: wire models, seams, codecs, checkpoint storage, the default WebSocket transport, timing and dedupe helpers, and deterministic fakes. This branch still has no constructor or run loop; it provides the pieces for later PRs. The shipped filter contract replaces srv1 with srv2, so existing srv1- checkpoint lineages go cold.

Foundations

  • Defines poll and push events, expanded filters, terminal reasons, continuation validation, backoff, timers, and delivery deduplication.
  • Adds FileCheckpointStore with bounded regular-file I/O, atomic saves, canonical keys, symlink-aware paths, and serialized access.
  • Adds WebSocketTransport over github.com/coder/websocket; proxy egress requires a custom CableTransport.
  • Adds deterministic fakes, shared transport contract tests, and updated conformance schemas and fixtures, including Windows-compatible FIFO coverage.

Contract and safety

  • Uses the published srv2 object digest and vectors, fixed subscription-key ordering, caller-resolved self, and server-owned actor_types vocabulary.
  • Requires exact wire keys and valid UTF-8, rejects duplicate or out-of-range values, and treats malformed checkpoint data as load failure.
  • Keeps credentials and server-controlled URLs, frame contents, close reasons, and dial details out of observer and error renderings.
  • Cancellation, close, and peer failures unblock reads and writes; dead fake connections cannot serve or accept later frames.

Written for commit 0ee3e83. Summary will update on new commits.

Review in cubic

Reconciled with the shipped feed contract (54c50ca)

bc3's feed shipped with more than the pre-merge branch this was written against, and the last commit brings the foundations to what the server serves (doc/api/sections/event_feed.md on bc3 master):

  • srv2 digest. A JSON object keyed by present dimension (actor_types, buckets, creators, exclude_performers, performers, reasons, types; bytewise key order; {} for the empty set), first 16 hex of SHA-256. The lineage namespace is srv2-; conformance/event-feed-digest/ carries the doc's eleven vectors, each recomputed independently. srv1 (the positional array) never shipped.
  • Filters gains Performers, ExcludePerformers, ActorTypes and Reasons (the inbox lane's dimension, digested here so its lineage key is well-defined). The subscribe identifier carries the first three after creators. The server's self literal is caller-resolved to an id: the connector performs no wire I/O with which to resolve a principal, and the checkpoint key is the server digest, which is defined over the resolved id (SPEC §23 Consumer Surface says why).
  • Event carries performed_by_id (both lanes, null for a direct action), details (verbatim bytes for the types that publish one — ids keep 64-bit fidelity, new keys survive) and the push-only actor_type. The push decoder requires the shipped eleven-key shape; a shipped frame would otherwise have been refused as an invalid frame on every delivery.
  • PollError carries the 409 body's position_digest / filters_digest.
  • The tier-2 schema and every fixture row take the new keys and dimensions; SPEC §23 is updated normatively for each item.

The inbox lane itself (a separate resource with its own item identity) is a fourth PR stacked on #778; the Layer-1 adapters over the generated operations follow once the spec operations land.

Copilot AI balanced review requested due to automatic review settings August 18, 2026 21:12
@jeremy jeremy added the go label Aug 18, 2026
@jeremy jeremy changed the title Event feed connector: foundations (1/2) Event feed connector: foundations (1/3) Aug 18, 2026

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

Pull request overview

Introduces the foundational Go event-feed components required by the forthcoming connector run loop.

Changes:

  • Adds event models, seams, codecs, filtering, deduplication, timing, and checkpoint persistence.
  • Adds a credential-isolated WebSocket transport and URL security policies.
  • Adds deterministic test fakes and extensive contract/unit coverage.

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.

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
AGENTS.md Registers the sanctioned event-feed architecture.
go/go.mod Adds the WebSocket dependency.
go/go.sum Records dependency checksums.
eventfeed/backoff.go Implements retry and repair jitter.
eventfeed/backoff_test.go Tests timing boundaries and saturation.
eventfeed/cable.go Implements Action Cable frame codecs.
eventfeed/cable_test.go Tests frame parsing and commands.
eventfeed/checkpoint.go Defines checkpoint identity and store seam.
eventfeed/clock.go Provides the timer abstraction and system clock.
eventfeed/clock_test.go Tests system timer registration.
eventfeed/continuation.go Validates continuation origins.
eventfeed/continuation_test.go Tests continuation security policy.
eventfeed/dedupe.go Implements delivered-event LRU deduplication.
eventfeed/dedupe_test.go Tests deduplication and eviction.
eventfeed/digest.go Implements canonical filter digests.
eventfeed/digest_test.go Verifies shared digest fixtures.
eventfeed/doc.go Documents the package architecture.
eventfeed/errors.go Defines terminal errors and reasons.
eventfeed/errors_test.go Tests error taxonomy and rendering.
eventfeed/event.go Defines event payloads.
eventfeed/event_test.go Tests payload presence semantics.
eventfeed/filestore.go Implements bounded atomic checkpoint storage.
eventfeed/filestore_test.go Tests persistence, locking, and file safety.
eventfeed/filters.go Defines and validates feed filters.
eventfeed/filters_test.go Tests validation and cloning.
eventfeed/redact.go Redacts observer-facing URLs.
eventfeed/redact_test.go Tests credential-safe URL rendering.
eventfeed/seams.go Defines connector interfaces and public types.
eventfeed/transport.go Implements cable URL policy.
eventfeed/transport_test.go Tests URL and proxy policy.
eventfeed/transport_contract_test.go Defines the shared transport contract.
eventfeed/websocket_transport.go Implements the default WebSocket transport.
eventfeed/websocket_transport_test.go Tests real transport behavior and security.
eventfeed/feedtest/clock.go Provides deterministic virtual time.
eventfeed/feedtest/clock_test.go Tests virtual timer behavior.
eventfeed/feedtest/minter.go Provides a scripted ticket minter.
eventfeed/feedtest/minter_test.go Tests minter scripting and cancellation.
eventfeed/feedtest/polls.go Provides a scripted poll source.
eventfeed/feedtest/polls_test.go Tests poll scripting and cancellation.
eventfeed/feedtest/store.go Provides a scripted checkpoint store.
eventfeed/feedtest/transport.go Provides a scripted cable transport.
eventfeed/feedtest/transport_test.go Tests fake connection behavior.

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

Comment thread go/pkg/basecamp/eventfeed/cable.go Outdated
Comment thread go/pkg/basecamp/eventfeed/websocket_transport.go
Comment thread go/pkg/basecamp/eventfeed/doc.go Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 01:43
@jeremy
jeremy force-pushed the event-feed-foundations branch from a2875be to 60a2870 Compare August 19, 2026 01:43
jeremy added a commit that referenced this pull request Aug 19, 2026
Review of 4cff076 (B1 excluded, since uncommitted at the time). All four P1s
reproduce; each fix is red-proven against the reported shape.

P1 — Observer.Disconnected still leaked ticket text. Both arguments carry
peer-controlled strings: a raw disconnect frame's reason, and a WebSocket close
reason rendered through the error. Both were BOUNDED by §9's cap, which limits
how much of a credential escapes rather than whether any does — the identical
trap dialFailure documents three review rounds of. The cable server is exactly
the party that knows the ticket: it was dialed with it.

Both now go through closed vocabularies. observableDisconnectReason keeps the
two reasons that change behavior and reports everything else as "other";
observableSocketError passes the connector's own sentinels and typed errors and
degrades anything from a seam to a generic cause. CloseError.Error() renders
only the code — an integer cannot carry a credential, and RFC 6455 codes are
what an operator classifies on; Reason stays a readable FIELD.

A canary planting a ticket in every peer-controlled teardown string found MORE
than was reported: raw seam read errors leak too, which seam documentation
cannot repair because the connector forwarded them verbatim. Four arms, all
red before and green after.

P1 — durableGate deadlocked reentrantly and blocked Close. It held the lock
across CheckpointStore.Save while Close waited for it: a store whose Save calls
Close self-deadlocks on the caller's own goroutine, and a merely stalled store
blocked EVERY Close indefinitely — contradicting the one thing Close promises
unconditionally. The two promises could not coexist, so the waiting one is
dropped: the gate is claimed and released atomically, Close latches and
returns, and a save that already claimed still completes. The guarantee is
unchanged in substance — no save COMMENCES after Close returns — with
commencing defined as claiming the gate, which takes no host code with it. The
old test asserted Close WAITS and is replaced by one asserting it does not; the
gate-holding variant deadlocks the new test at 40s.

P1 — Close precedence, reopened by #763. Arming staleness before Connected also
starts the pump before it, so a fatal frame can already be queued when a
Connected callback calls Close, leaving two ready select cases. Reproduced:
25/50 rounds emitted a terminal element after Close returned. Fixed at the ONE
exit (emitTerminal) rather than per-select — many selects, one exit, and a rule
every future select must remember is what produced this.

P1 — B2 discarded an earlier socket verdict. A deferred protocol-fatal followed
by a positionless page took poll_failed, because disposal clears the deferral.
The failed-poll branch already dispatches the deferral first, with a comment
giving this exact reason; the new guard did not follow it. Now it does.

Also fixed: TestNoCheckpointSaveCommencesAfterClose was vacuous (it closed
before the run reached a page) and now closes from Observer.PageDelivered, the
callback immediately preceding the save; the cancellation check after the
checkpoint load covers every result rather than only the failure, since a
found-empty result became terminal and a successful one let the run fire
Connecting after Close; and deliver()'s stale "no delivery begins after Close
returns" claim is corrected in place — it is a check-then-act, and the honest
guarantee is the one Close states.

Two of these touch foundations files that belong to #777 — CloseError in
seams.go and its test. They stay here because the leak is only observable
through the loop's observer path, which is this PR's, and the canary that
proves it lives here. TestCloseError_Message is INVERTED, not adjusted: it
required Error() to render the peer's reason, so it pinned the wrong contract.

Verified: build, vet, -race, 22/22 fixtures, go-lint 0 issues, gosec 0 issues.

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

Pull request overview

Copilot reviewed 41 out of 42 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

go/pkg/basecamp/eventfeed/websocket_transport.go:414

  • A concurrent repeat Close returns as soon as closed is set, while the first call may still be waiting for the graceful handshake and has not canceled lifetime. Pending reads/writes can therefore remain blocked after that Close has returned, violating the CableConn.Close contract. Publish a shared completion channel/result so every concurrent caller waits for the first teardown to finish.
    go/pkg/basecamp/eventfeed/filestore.go:398
  • This rename does not preserve the documented support for a symlink to a regular store file: atomic rename replaces the symlink itself, leaving its target unchanged. A later consumer opening the target sees the stale checkpoint, while this spelling sees a new unrelated file. Either reject symlink paths consistently or resolve and lock/write the target identity without breaking atomic replacement.
    go/pkg/basecamp/eventfeed/websocket_transport.go:310
  • The method documents cancellation before local-close precedence, but this early return reverses it when both happen before entry. That can turn a canceled operation into a socket failure; WriteFrame already checks ctx.Err() first. Apply the same ordering here.

Comment thread go/pkg/basecamp/eventfeed/filestore.go Outdated
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Suppressed comments, round 2 — three findings, two verdicts and a stop

Copilot's review on 60a28700d put three findings in a Suppressed comments block, which never becomes a thread. Answering them here since there is nothing to reply to.

1. websocket_transport.go:310 — cancellation vs local close on entry — fixed (b15e8d031)

True, and the two methods disagreed with each other, not just with the doc: ReadFrame tested the local close first on entry while WriteFrame tested the context first. The case is reachable — cancel the run context, then close the connection is the shutdown a run loop performs — and it reported a cancelled read as a connection failure.

The assertion went into the shared transport contract, not this transport's own tests, because it is a statement about the seam every implementation owes. Red proof: the feedtest fake already satisfied it and passed; the real transport failed the same case (exit 1). That asymmetry is the finding in one line.

2. websocket_transport.go:414 — concurrent repeat Closetrue, and deliberately not doing it

Correct as stated: a second concurrent Close returns as soon as closed is set, while the first may still be inside the graceful handshake and has not cancelled lifetime yet.

What that costs is now bounded by closeGraceBudget, one second, because the first caller cancels the lifetime in both arms of its select — the budget always fires. Before fae998b16 the same window was coder/websocket's 5s+5s. So the residual is: for at most a second, a second caller cannot yet assume reads are released, while the first caller is still waiting on exactly that.

The proposed remedy — a shared completion channel every concurrent caller waits on — buys that second by inverting a property the type documents on purpose: repeats are no-ops that return at once. It is a mechanism that earns its keep only on a path where two goroutines close one connection simultaneously, and it makes the common repeat-close slower to fix a one-second staleness on the rare one. The contract clause is about the operation releasing blocked I/O, and the first Close does that within its budget; it does not promise that a second caller observes the release as already complete.

Declining on merit, written here rather than left open. If a run loop ever does need "closed and quiesced" as one answer, that wants an explicit await on the seam, not a lock inside Close.

3. filestore.go:398 — symlinked store path vs atomic rename — stopping here, not patching

The finding is real. read uses Stat, not Lstat, specifically so "a symlink to a regular file still works — an operator pointing the store through a symlink is ordinary", and writeAtomic's os.Rename then replaces the symlink itself. Read follows the link; write destroys it, and any other consumer of the target keeps reading a checkpoint that stopped advancing.

I am not writing this patch, and the reason is the shape rather than the merit. Counting rounds on this one file:

  1. dcdd1d65b — the read must refuse non-regular files, bound its size, and fold the lock key's case.
  2. This round, in the thread above — the write must enforce the bound the read does.
  3. This — the write must preserve the identity the read follows.

Three rounds, each individually small, each individually true, all circling one question nobody has been asked: what is a store file's identity — the path spelling, the resolved target, or the inode? The existing code has already answered it three different ways in three places. canonicalStorePath deliberately does not resolve symlinks, and says why: resolution requires the file to exist, and this file is created on the first Save, so a resolved key changes identity mid-life. read deliberately does follow them. writeAtomic replaces them. Each choice is defensible alone; together they are not one contract.

Every candidate remedy trades a different documented property away:

  • Reject symlinks in read (Lstat) — removes support the doc calls ordinary.
  • Resolve and rename onto the target — breaks atomic replacement across filesystems, and reintroduces the mid-life identity change the lock key was written to avoid.
  • Document that a symlinked path is replaced by a regular file on first Save — honest, free, and leaves the surprise in place.

That is an authoring decision about what the store is, not a review-round fix, and picking one here would be the fifth selector on an instrument nobody has sized. Flagging it for a human call rather than guessing.


Everything else from this round is on b15e8d031. make go-check (vet + golangci + tests) and go test -race are clean under LC_ALL=C.

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

Pull request overview

Copilot reviewed 41 out of 42 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

go/pkg/basecamp/eventfeed/websocket_transport.go:423

  • Concurrent callers do not observe completion of the same close. The first caller sets closed before starting/waiting for the handshake, so a second caller can return nil immediately while the connection lifetime is still active and pending I/O remains blocked for up to the close budget. Since Close is documented as safe from any goroutine and as unblocking reads/writes, make repeated callers wait on a shared close-completion signal (and return the completed result) instead of treating an in-progress close as complete.

Comment thread go/pkg/basecamp/eventfeed/filestore_test.go Outdated
Comment thread go/pkg/basecamp/eventfeed/cable.go Outdated
Comment thread go/pkg/basecamp/eventfeed/seams.go
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Stopping here: this is the fourth round on one question, not two more patches

Round 3 on b15e8d031 raises two findings that are the same finding, and it is a finding this branch has already paid three rounds for. Naming the pattern instead of writing the next patch.

The pattern

dialFailure's own doc comment records the history, in the code, on this branch:

Three review rounds found three different spellings that slipped a model in turn: a value below a length threshold, a percent-encoded form, and a query carrying the credential with no = in it at all. That is not three bugs, it is one control that has to anticipate its own input, and the peer chooses the input.

That round ended by replacing the redactor with a closed vocabulary keyed on error types — and generalised it to exactly one call site. Two other observer-facing renderings were left on the older model, "compose once and bound by §9's MAX_ERROR_MESSAGE_LENGTH":

  • invalidFrameError (cable.go) — appends cause.Error(), and time.Time's decoder quotes the offending input.
  • CloseError.Error() (seams.go) — renders the peer's close Reason.

Round 3's two comments are the observation that truncation is not redaction, applied to those two sites. That observation is correct, and it is the same observation the earlier three rounds made. Four rounds, one question, two models live in one package.

What I think the real question is

Is MAX_ERROR_MESSAGE_LENGTH still the rule for peer-derived text in observer-facing errors, or did the closed-vocabulary decision supersede it everywhere peer text can reach an observer?

Until that is answered, any patch here is the fourth selector on an instrument nobody has sized — and the two obvious local fixes are both wrong in an instructive way:

  • Drop the cause from invalidFrameError. Most causes are ours and already closed — decodeMessageEvent emits missing required key %q with the key drawn from a fixed nine-element set. Deleting the append to suppress the one decoder that quotes input throws away the package's own diagnostics and leaves the class no better specified. The right shape is dialFailure's: classify causes by type, render from a fixed vocabulary. That is a design change, not an edit.
  • Render only the status code in CloseError.Error(). Cleaner in isolation — Reason stays on the struct, so callers keep it. But §23 dispatches on the reason string and documents Observer.disconnected as carrying an invalid-frame indication, so what these errors are allowed to say is a §23 conformance surface, mirrored in conformance/event-feed/schema.json and owed by all six SDKs. Changing it in Go alone would diverge the reference implementation from the spec the other five will implement, silently.

So this is a SPEC §23/§9 decision with a conformance-schema and six-SDK blast radius, not a Go-file fix, and it should not be made inside a review round on the foundations PR. Flagging it for a human call; the threads carry the same reasoning and are resolved so the PR is not held open on a decision that is not mine.

On merit, for the record

The findings are true but the actor is narrow: the entity that can trigger either is the cable server the mint pointed us at, which already holds the ticket — it received it in the handshake URL. Nothing is disclosed to a party that did not have it; what is at stake is our own short-lived credential landing in the operator's log aggregator. Real, worth fixing, and not urgent enough to justify guessing at the spec.

Also in round 3

  • filestore_test.go:824 (Windows / syscall.Mkfifo) — declined, with evidence, in the thread. GOOS=windows GOARCH=amd64 go build ./... on this head exits 0: the shipped library is Windows-clean; only the test file is not, and no workflow in this repo runs Windows.
  • The suppressed comment on websocket_transport.go:423 is round 2's concurrent-Close finding re-raised verbatim. It was declined with reasoning and that decline stands; a bot repeating an answered finding is more evidence about the instrument than about the code.

@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Tracked as #788, so the analysis above survives this PR's squash-merge rather than living only in a comment thread.

The issue carries the question as posed here — whether MAX_ERROR_MESSAGE_LENGTH still governs peer-derived text or the closed-vocabulary decision superseded it — plus both wrong local fixes and why, the narrow-actor severity read, and the two shapes that would close it. Filing it does not decide it; the deciding constraint stays that §23's reason-string dispatch is a cross-SDK conformance surface, so the answer belongs in SPEC before it belongs in Go.

Not holding this PR on it. The threads are resolved because the decision isn't this PR's to make.

@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

A stacked-PR failure mode worth writing down: a moving base can silently disable Copilot review

Recording this on the base PR because the diagnosis is not discoverable from the symptom, and the next person to hit it will be looking at #777's history rather than at the child PR.

Symptom. Copilot posts, in place of a review:

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

What actually happened. #705 is stacked on this branch. When event-feed-foundations was force-pushed and rebased onto a newer main, #705's own head still sat on the old tip, so GitHub computed its diff from a stale merge base — folding this branch's commits into the child's diff:

additions files
Against the stale merge base 20,678 59
Against the correct base 12,698 19

The child PR crossed a reviewer's size limit without a single line of its own changing. Rebasing --onto the new base restored the real numbers and Copilot reviewed normally on the next push.

Why it is worth a note rather than a shrug. The failure is silent in the direction that matters: gh pr checks stays green, the thread count does not move, and the review that never happened looks exactly like a review that found nothing. It cost a full round here to notice, and only because the review body was read rather than the thread count.

Diagnosis, for next time. If a stacked PR's reviewer goes quiet or refuses on size, compare what GitHub thinks the diff is against what the branch actually carries:

gh pr view <n> --json additions,changedFiles   # GitHub, from the merge base
git diff --shortstat origin/<base>...HEAD      # what the branch really adds

A large disagreement means the base moved. git merge-base --is-ancestor <old-base> origin/<base> then says whether a plain rebase suffices or whether --onto is needed because the base was rewritten.

Both of this branch's moves have now been absorbed downstream: a2875be2c60a28700d (rewritten, needed --onto) and 60a28700db15e8d031 (fast-forward). No action is needed here — this is a note for the pattern, not a request.

jeremy added a commit that referenced this pull request Aug 19, 2026
Review of 4cff076 (B1 excluded, since uncommitted at the time). All four P1s
reproduce; each fix is red-proven against the reported shape.

P1 — Observer.Disconnected still leaked ticket text. Both arguments carry
peer-controlled strings: a raw disconnect frame's reason, and a WebSocket close
reason rendered through the error. Both were BOUNDED by §9's cap, which limits
how much of a credential escapes rather than whether any does — the identical
trap dialFailure documents three review rounds of. The cable server is exactly
the party that knows the ticket: it was dialed with it.

Both now go through closed vocabularies. observableDisconnectReason keeps the
two reasons that change behavior and reports everything else as "other";
observableSocketError passes the connector's own sentinels and typed errors and
degrades anything from a seam to a generic cause. CloseError.Error() renders
only the code — an integer cannot carry a credential, and RFC 6455 codes are
what an operator classifies on; Reason stays a readable FIELD.

A canary planting a ticket in every peer-controlled teardown string found MORE
than was reported: raw seam read errors leak too, which seam documentation
cannot repair because the connector forwarded them verbatim. Four arms, all
red before and green after.

P1 — durableGate deadlocked reentrantly and blocked Close. It held the lock
across CheckpointStore.Save while Close waited for it: a store whose Save calls
Close self-deadlocks on the caller's own goroutine, and a merely stalled store
blocked EVERY Close indefinitely — contradicting the one thing Close promises
unconditionally. The two promises could not coexist, so the waiting one is
dropped: the gate is claimed and released atomically, Close latches and
returns, and a save that already claimed still completes. The guarantee is
unchanged in substance — no save COMMENCES after Close returns — with
commencing defined as claiming the gate, which takes no host code with it. The
old test asserted Close WAITS and is replaced by one asserting it does not; the
gate-holding variant deadlocks the new test at 40s.

P1 — Close precedence, reopened by #763. Arming staleness before Connected also
starts the pump before it, so a fatal frame can already be queued when a
Connected callback calls Close, leaving two ready select cases. Reproduced:
25/50 rounds emitted a terminal element after Close returned. Fixed at the ONE
exit (emitTerminal) rather than per-select — many selects, one exit, and a rule
every future select must remember is what produced this.

P1 — B2 discarded an earlier socket verdict. A deferred protocol-fatal followed
by a positionless page took poll_failed, because disposal clears the deferral.
The failed-poll branch already dispatches the deferral first, with a comment
giving this exact reason; the new guard did not follow it. Now it does.

Also fixed: TestNoCheckpointSaveCommencesAfterClose was vacuous (it closed
before the run reached a page) and now closes from Observer.PageDelivered, the
callback immediately preceding the save; the cancellation check after the
checkpoint load covers every result rather than only the failure, since a
found-empty result became terminal and a successful one let the run fire
Connecting after Close; and deliver()'s stale "no delivery begins after Close
returns" claim is corrected in place — it is a check-then-act, and the honest
guarantee is the one Close states.

Two of these touch foundations files that belong to #777 — CloseError in
seams.go and its test. They stay here because the leak is only observable
through the loop's observer path, which is this PR's, and the canary that
proves it lives here. TestCloseError_Message is INVERTED, not adjusted: it
required Error() to render the peer's reason, so it pinned the wrong contract.

Verified: build, vet, -race, 22/22 fixtures, go-lint 0 issues, gosec 0 issues.
Copilot AI review requested due to automatic review settings August 19, 2026 06:52
@jeremy
jeremy force-pushed the event-feed-foundations branch from b15e8d0 to f241597 Compare August 19, 2026 06:52
jeremy added a commit that referenced this pull request Aug 19, 2026
Review of 4cff076 (B1 excluded, since uncommitted at the time). All four P1s
reproduce; each fix is red-proven against the reported shape.

P1 — Observer.Disconnected still leaked ticket text. Both arguments carry
peer-controlled strings: a raw disconnect frame's reason, and a WebSocket close
reason rendered through the error. Both were BOUNDED by §9's cap, which limits
how much of a credential escapes rather than whether any does — the identical
trap dialFailure documents three review rounds of. The cable server is exactly
the party that knows the ticket: it was dialed with it.

Both now go through closed vocabularies. observableDisconnectReason keeps the
two reasons that change behavior and reports everything else as "other";
observableSocketError passes the connector's own sentinels and typed errors and
degrades anything from a seam to a generic cause. CloseError.Error() renders
only the code — an integer cannot carry a credential, and RFC 6455 codes are
what an operator classifies on; Reason stays a readable FIELD.

A canary planting a ticket in every peer-controlled teardown string found MORE
than was reported: raw seam read errors leak too, which seam documentation
cannot repair because the connector forwarded them verbatim. Four arms, all
red before and green after.

P1 — durableGate deadlocked reentrantly and blocked Close. It held the lock
across CheckpointStore.Save while Close waited for it: a store whose Save calls
Close self-deadlocks on the caller's own goroutine, and a merely stalled store
blocked EVERY Close indefinitely — contradicting the one thing Close promises
unconditionally. The two promises could not coexist, so the waiting one is
dropped: the gate is claimed and released atomically, Close latches and
returns, and a save that already claimed still completes. The guarantee is
unchanged in substance — no save COMMENCES after Close returns — with
commencing defined as claiming the gate, which takes no host code with it. The
old test asserted Close WAITS and is replaced by one asserting it does not; the
gate-holding variant deadlocks the new test at 40s.

P1 — Close precedence, reopened by #763. Arming staleness before Connected also
starts the pump before it, so a fatal frame can already be queued when a
Connected callback calls Close, leaving two ready select cases. Reproduced:
25/50 rounds emitted a terminal element after Close returned. Fixed at the ONE
exit (emitTerminal) rather than per-select — many selects, one exit, and a rule
every future select must remember is what produced this.

P1 — B2 discarded an earlier socket verdict. A deferred protocol-fatal followed
by a positionless page took poll_failed, because disposal clears the deferral.
The failed-poll branch already dispatches the deferral first, with a comment
giving this exact reason; the new guard did not follow it. Now it does.

Also fixed: TestNoCheckpointSaveCommencesAfterClose was vacuous (it closed
before the run reached a page) and now closes from Observer.PageDelivered, the
callback immediately preceding the save; the cancellation check after the
checkpoint load covers every result rather than only the failure, since a
found-empty result became terminal and a successful one let the run fire
Connecting after Close; and deliver()'s stale "no delivery begins after Close
returns" claim is corrected in place — it is a check-then-act, and the honest
guarantee is the one Close states.

Two of these touch foundations files that belong to #777 — CloseError in
seams.go and its test. They stay here because the leak is only observable
through the loop's observer path, which is this PR's, and the canary that
proves it lives here. TestCloseError_Message is INVERTED, not adjusted: it
required Error() to render the peer's reason, so it pinned the wrong contract.

Verified: build, vet, -race, 22/22 fixtures, go-lint 0 issues, gosec 0 issues.

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

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (1)

go/pkg/basecamp/eventfeed/seams.go:339

  • DialError.Error() bypasses the 500-byte cap that §23 says still applies to other error renderings. checkCableURL places the server-supplied scheme or explicit port in Reason, and net/url accepts arbitrarily long valid schemes, so a malformed mint response can produce an arbitrarily large observer/log message. Apply the package truncation helper to the composed result.

jeremy and others added 18 commits September 15, 2026 21:48
…oint

Codex found the quiet path around the store's corrupt-file verdict:
corruption that rewrites a lineage's FlatKey into some other valid JSON
string decoded cleanly, the lookup missed, and Load reported MISSING — the
connector entering at the present and skipping history over damage the
tri-state contract says must be Failed.

Every decoded key is now validated against the grammar the package itself
writes: isCanonicalFlatKey parses the key as a JSON array of exactly four
strings and re-encodes it through FlatKey's own writer — the round-trip is
the whole check, so any spelling Save could not have produced (wrong arity,
wrong types, whitespace, a different escape of the same text) is malformed
by construction, while a well-formed key belonging to another lineage stays
what it is: absence, not corruption. Red first: all five malformed spellings
loaded as Missing; the foreign-valid-key control pins that Missing survives.
Two Codex findings, one class: mint URLs that are permanently unusable were
riding the reconnect cycle as if retrying could help.

A minted cable URL carrying a fragment was accepted, although a fragment is
never part of a request target: the dial would silently connect to
something other than what the mint named, or report the stack's rejection
as transient — re-minting the same URL forever. checkCableURL now refuses a
non-empty fragment with a value-free reason (a fragment is server text and
can carry the ticket; the never-echoes pin drives one through with a
planted ticket).

And a 3xx with NO Location never invokes CheckRedirect — net/http hands it
back as a normal answer — so the redirect sentinel could not see it and the
fallback classified DialTransient: the same endless re-mint against an
endpoint that will redirect forever. The dial now classifies a redirect
status on the handshake response as policy, sentinel or no sentinel, with
the same fixed reason. The one shape left transient is a 3xx whose Location
fails to parse: net/http errors before CheckRedirect, untyped and with no
response retained, and classifying it would take message-text matching the
closed vocabulary forbids — named in the comment, bounded by the reconnect
cycle. Red first: the fragment URL passed the policy check, and the bare
302 came back "kind transient (server answered HTTP 302)".
Both bots pressed on the shape round 14 left transient — a 3xx whose
Location fails to parse — and they were right that it cannot stay: §23 makes
every mint-URL redirect a policy refusal, and a permanently redirecting
endpoint re-minted forever is exactly the failure the classification exists
to prevent. The round-14 boundary was real but misplaced: net/http parses a
redirect's server-controlled Location BEFORE consulting CheckRedirect
(client.go: the parse failure returns an untyped error with no response
retained), so no sentinel and no status check downstream of the client could
ever see this case. ErrUseLastResponse would not have helped for the same
ordering reason — verified against the standard library before choosing.

So the refusal moves upstream of the machinery entirely: a package-owned
redirectInterceptor wraps the cable transport and converts every
redirect-class response into the sentinel at RoundTrip, before the client's
redirect handling — and so before the Location header is parsed, or even
looked at. Every 3xx is now one policy refusal: valid Location, absent,
malformed alike, which also retires round 14's response-status branch as
dead code. CheckRedirect stays as a one-line backstop for the accident
class (a future Transport swap dropping the wrapper), and the wiring test
now pins the wrapper itself. Red first: the malformed-Location dial came
back "cable dial failed (transient)".

The timer in Close also stops leaking: time.After left the close budget's
native timer armed for the remainder of its second after a fast peer
acknowledgement, one per clean close. It is a stoppable timer now —
deliberately still a native one, outside the injected Clock, because Close
must work after the loop's clock is gone; the hygiene is Stop, not registry
accounting. No red: the change is mechanical and its effect (an armed
runtime timer) has no cheap observation surface.
Codex extended the store's duplicate-key lesson to the codec: encoding/json
keeps the LAST duplicated member silently, so
{"type":"ping","type":"disconnect","reason":"invalid_event_stream_command"}
dispatched as a genuine protocol-fatal disconnect — member order selecting
control behavior — and a second `id` in a correlated payload silently
decided which event was delivered.

The detector is the decoder's own tokenizer, as it is in the store:
topLevelMemberCount walks the object with nested values skipped, and a
count exceeding the decoded map's size is a duplicate (escape-resolved,
exactly as the decoder resolves keys). The envelope check classifies as the
parse shape, the event payload's as the decode shape, and neither renders a
byte of the frame. Red first: both duplicate frames parsed as their
last-member reading, and the duplicate-id payload decoded as a valid event.
RFC 6455 subprotocol tokens are exact: a server selecting
"ActionCable-V1-Json" selected a protocol this client never offered, and
the case-folded compare exposed the connection anyway. Exact equality
now; the wrong-case pin stages the selection through a raw upgrade
handler, since a conforming library will not select an unoffered
spelling. Red-proved against the folded compare.
…t weather

Copilot completed the subprotocol negotiation matrix: the absent and
case-variant selections were already permanent policy refusals, but a 101
selecting a protocol the dial never OFFERED is refused by coder/websocket
during verification, before any conn exists — so it fell to the transient
fallback, re-minting forever against a server that selects its bogus
protocol deterministically.

The library returns the handshake response on that path (verified in
dial.go: every verifyServerResponse failure comes back with resp), so the
classification is structural, no error-text matching: a 101 whose
Sec-WebSocket-Protocol is non-empty and not the one offer is the policy
verdict, with a fixed reason — the peer-controlled header value is never
rendered. Red first, via a hand-rolled upgrade computing a correct
Sec-WebSocket-Accept and selecting "bogus-protocol-sekrit": the dial came
back "cable dial failed (transient) … (server answered HTTP 101)", and the
new pin also walks the rendering for both the ticket and the bogus protocol
name.
Codex closed the write-side half of the U+FFFD story: json.Marshal does not
refuse invalid UTF-8 either — it silently swaps each invalid sequence on
the way OUT — so a custom PollSource's opaque position mutated before it
ever reached disk, where the load gates can only ever see a well-formed
file holding the wrong bytes. Red first: Save of "pos-\xff-1" returned nil
and the store held "pos-�-1" on disk.

Save now runs every key component and the position through
checkIdentityText before touching the file. The verdict is usage, not
corruption: the bad value is the caller's own, exactly the class
checkIdentityText already rules at construction — and the rendering carries
the field labels, never the values. A refused save leaves no file behind.
Codex claimed a P1: a write racing an observed peer close returns
coder/websocket's own close error, rendering the peer-selected reason the
read path already withholds. Verified against the pinned library and the
claim does not hold: v1.8.15's write path returns net.ErrClosed sentinels
for every closed-connection shape and consults closeReceivedErr only on
reads, so the peer's reason structurally cannot surface from Write — and
driving the exact interleaving (a read observes the close, then a write
fails) produced "use of closed network connection", reason-free. Adding the
read path's translation now would be a branch no test can reach.

But that is the library's internals, not its contract, so the interleaving
stays pinned as a tripwire: the new test walks the write error's chain for
a planted ticket-bearing close reason. A dependency bump that starts
surfacing the recorded close from Write goes red here, and the sanitizing
treatment moves to the write path then — forced by the failure, not by
speculation. Downstream is already closed either way: the run loop's
observer vocabulary reduces every unrecognized socket error to its generic
sentinel, so no rendering surface reaches an observer even under a changed
library.
…the way down

Two Codex findings against the file store.

Load now runs the same checkIdentityText gate Save got last round, because
the read side was the sharper half: FlatKey encodes an invalid component to
U+FFFD silently, so Load("open\xffclaw") MATCHED a lineage legitimately
saved under "open�claw" and returned another consumer's cursor for a
key that was never valid. Red first: the pin seeded that exact lineage and
Load handed back ("pos-other-consumer", true, nil). Usage verdict, before
any lookup, labels rendered and values never.

And writeAtomic gains the classic sync pair — staged file before the
rename, directory after it — because the Durability section's own priced
worst case was false without it. The doc disclaimed fsync on the argument
that a crash costs "a re-entry at an older position, never correctness";
but a crash can persist the rename's metadata before the staged data
blocks, leaving a zero-length or garbage file where BOTH versions used to
be — and a torn file fails the next Load, which is Terminal(checkpoint_load)
with zero wire attempts: a feed that will not start, over a file whose
whole job was surviving the crash. The doc now prices what the syncs buy
and what they cost (two fsyncs per accepted poll page). No red is possible
for a kernel crash and none is claimed: the mechanism is the well-known
pair, a failed directory sync reports Failed (the retried save rewrites the
same content), and no counting seam was added just to observe a syscall.
Codex found the mirror of the existing overflow clamp: a positive interval
below the nanosecond, jittered downward, lands the float product in (0,1)
and the Duration conversion floors it to zero — a repair timer that fires
immediately on every cycle, tight-looping poll walks against a caller who
asked for a positive interval. The clamp's other end now exists: anything
positive that would floor to zero returns the nanosecond instead. Red
first: repairJitter(1ns) returned 0s on every downward draw in the table.
Codex's P1 turned the package's own value invariant against the proxy
carve-out. The old design proxied wss:// handshakes on the theory that a
CONNECT tunnel shows the proxy only the host — but the host is a
server-selected URL component, and every round of this review has held
that ANY server-controlled component can BE the opaque ticket.
"wss://<ticket>.cable.example/cable?ticket=…" would put the credential in
the proxy's plaintext CONNECT target and its access log: the never-log
invariant, violated at a third party. The pre-fix demonstration drove
exactly that shape through cableProxy and got the CONNECT target back.

So the transport now sets Proxy nil by construction — the OAuth discovery
client's stance — and cableProxy, its environment hook, and both
hook-driven proxy tests are gone with the carve-out they existed to police:
with no resolver there is nothing to hook, and the wiring pin (Proxy nil)
is deliberately structural, because an env-based proxy test goes green for
the wrong reason the moment anything in the binary warms
http.ProxyFromEnvironment's package-wide cache. A deployment that can only
egress through a proxy implements CableTransport — the documented
extension point — and owns that trade knowingly; the old comment's
"breaking every deployment behind one" objection is answered in the doc
where the next reader will look for it.
Copilot: FlushFileBuffers rejects directory handles, so the post-rename
directory sync turned every successful Windows replacement into a
reported failure. The sync is skipped there with the reasoning on the
branch (NTFS journals rename metadata; the file-content sync still runs
everywhere). And mustParseURL lost its last caller in the proxy removal
-- the Lint job caught it; removed with its import.
Copilot: the round-16 classifier folded case, so a wrong-case selection
(a protocol this dial never offered, refused by the library before any
conn exists) read as matching the offer and fell to transient -- the
re-mint-forever shape the classifier exists to stop. Exact comparison,
matching the accepted-connection check; the existing wrong-case pin
drives this branch since the library refuses before returning a conn.
Three Codex findings, one file.

The duplicate-key walk counted string TOKENS, and a null position
contributes a key token but no value token — so one duplicated key (surplus
two) plus two null-valued entries (deficit two) balanced the
strTokens == 2*len(entries) equality and the duplicated lineage loaded
last-wins. Red first with exactly that file: six tokens, three entries,
Load returned ("pos-2", true, nil). Detection now counts MEMBERS with the
codec's topLevelMemberCount — value types cannot cancel anything — and a
null position itself stays priced by the empty-position rule at lookup.

The lock key lowercased instead of folding: ſ (U+017F) case-folds together
with S and s on APFS/NTFS — one physical file — while ToLower leaves ſ
alone, so two spellings took two mutexes and raced the read-modify-write
the registry exists to serialize. Red first: ſtore.json kept its own key.
Each rune now maps to the minimum of its unicode.SimpleFold orbit, covering
every one-rune fold; the honest edges — full-fold multi-rune expansions and
normalization — are named in the doc as deliberately unchased, with
byte-identity the documented guarantee and unseen aliases degrading to the
documented cross-process last-writer-wins.

And a failed filepath.Abs no longer falls back to the relative spelling —
the identity-split class in its purest form: a path that names a DIFFERENT
file after every later chdir while keeping the old spelling's lock. The
constructor keeps its signature; the store records the resolution error and
every Load and Save reports it before touching the filesystem, with a
private mutex since it serializes with nothing. The pin drives it by
removing the working directory; on this macOS Getwd still resolves a
removed cwd, so the test self-skips here and bites where the platform
allows — stated plainly rather than simulated around.
…n it

Codex asked for the dial path's closed-vocabulary flattening on ReadFrame's
raw fallthrough, on the claim that a TCP read failure's net.OpError renders
a server-selected address that can carry the ticket. The claim fails on
structure, and the asymmetry with dialFailure is exactly the line this
review has drawn all along. A dial error can wrap a *url.Error rendering
the full ticket-bearing URL — unbounded server-chosen text, so the dial
path flattens. A post-handshake read error cannot render any dialed-URL
component: wsConn retains no URL at all (the leak is inexpressible even by
mutation), an OpError's address is the RESOLVED IP plus the CONNECTED port
— a number in 1-65535, which an opaque ticket cannot be, the dial-status
decline's reasoning on an even harder boundary since the port also accepted
a TCP connect — and every peer-chosen text channel in a read error is
already mapped: close reasons through the withholding CloseError, the read
limit through ErrFrameOversize. Flattening would spend the one genuinely
diagnostic cause (reset vs timeout vs EOF) to remove text that cannot carry
a credential, and the run loop's observer vocabulary reduces unrecognized
read errors to its generic sentinel before any logging surface regardless.

The fallthrough now says so where the next reviewer will look, and the
channel is pinned the way the write path is: a tripwire that dials through
a NAME (so resolution is exercised), kills the peer's TCP abruptly, and
walks the read error's chain for the ticket, the query, and the dialed
hostname. Today it logs "failed to read frame header: EOF" — library prose,
nothing dialed; a future change that starts retaining or rendering the URL
goes red here.
…ce a read has killed the connection

decodeMessageEvent accepted a payload whose nine keys were all present and
well-typed but outside conformance/event-feed/schema.json's value bounds —
id, bucket_id, creator_id or recording_id below 1, or an empty kind,
event_type or action — and delivered it, into the dedup ledger under a key
nothing real can share. Those are the event_decode invalid-frame shape now.

feedtest.Conn kept recording writes after ReadFrame had surfaced a peer
close, a scripted read failure or the latched oversize violation, though a
real WebSocket is dead after any of them; a connector test could therefore
accept a subscribe ordered after the death that production routes through
socket-failure recovery. The surfaced read outcome now latches, and a later
WriteFrame fails with it.
A WriteFrame already blocked under StallWrites waited for cancellation or a
local Close even after a concurrent ReadFrame had surfaced a peer close, a
read failure or an oversize violation: the stall loop neither tested `dead`
nor was broadcast to when it latched. A real socket's blocked write fails
when the connection dies under it, so a connector test could hang, or take
the deadline path where socket-failure recovery was the thing under test.
The loop now wakes on `dead` and the two latch sites broadcast; a test
starts the writer before surfacing each death and expects the death back,
without waiting for the writer to reach the stall — the latched death is
what it returns either way.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54c50caa03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/pkg/basecamp/eventfeed/cable.go
Comment thread go/pkg/basecamp/eventfeed/transport.go
Comment thread go/pkg/basecamp/eventfeed/filestore.go
bc3's event feed shipped with more than the pre-merge branch the
foundations were written against, and the connector's filters, digest and
event model each diverged from what the server now serves.

The filter digest is srv2, not srv1: a JSON object keyed by present
dimension (actor_types, buckets, creators, exclude_performers, performers,
reasons, types; bytewise key order; `{}` for the empty set) rather than a
positional three-element array. Absent dimensions contribute no bytes, so
a dimension added later never moves an existing filter set's digest. The
checkpoint-lineage namespace moves to `srv2-` with it, and the digest
fixture family carries the doc's eleven published vectors, each recomputed
independently.

Filters gains the shipped dimensions: performers and exclude_performers
(the loop guard for an agent that acts on what it hears), actor_types, and
reasons (the inbox lane's own dimension, digested here so its lineage key
is well-defined). The subscribe identifier carries the first three in
fixed key order after creators. The server's `self` literal is resolved
by the caller to an id: the connector performs no wire I/O with which to
resolve a principal, and the checkpoint key is the server digest, which
is defined over the resolved id.

Event carries performed_by_id (present on both lanes, null for a direct
action), details (verbatim bytes for the types that publish one, so ids
keep 64-bit fidelity and new keys survive) and the push-only actor_type.
The push decoder requires the shipped eleven-key shape, with
performed_by_id the one key whose null is a value; a shipped frame would
otherwise have been refused as an invalid frame on every delivery.

PollError carries the 409 body's position_digest and filters_digest.
SPEC §23 is updated normatively for each of these.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e7181bc44

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/pkg/basecamp/eventfeed/feedtest/transport.go
Comment thread COORDINATION.md
Comment thread SPEC.md
Comment thread SPEC.md

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

🟡 Changes recommended

URL scheme handling, redaction documentation, and virtual-clock determinism remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

go/pkg/basecamp/eventfeed/transport.go:59

  • checkCableURL accepts uppercase WS/WSS schemes, but coder/websocket v1.8.15 switches on the original scheme using exact lowercase cases and rejects WSS before network I/O. Dial then wraps that deterministic failure as DialTransient, so the connector will re-mint and retry forever for a URL this policy explicitly accepted. Either normalize only the scheme before calling the WebSocket library or reject non-lowercase spellings here as DialPolicy; add an end-to-end transport case for the accepted uppercase URL.
  • Files reviewed: 65/66 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread go/pkg/basecamp/eventfeed/redact.go
Comment thread go/pkg/basecamp/eventfeed/feedtest/clock.go
…he shipped feed

ReadFrame checks the latched death before the queue, so a frame served
after a surfaced peer close or read failure is never delivered: the
connection stays dead, as a real socket does, and a late Serve cannot
revive it for one more read.

COORDINATION.md's event-feed item names the BC3 PRs that shipped the
feed to master and points at the spec-operations PR for the generated
layer and the gap registry, instead of calling the branch unmerged.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb7c5b7c1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread conformance/event-feed/schema.json Outdated
Comment thread go/pkg/basecamp/eventfeed/filestore_test.go Outdated
@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Review threads: 11 resolved (5 fixed, 6 declined with the reasoning in each thread). Head 0ee3e83; Codex reported on the previous heads and this push awaits its round; Copilot last reviewed 7e7181b — a re-request past the one made here needs a human.

Declined:

…ctor_types are the server's vocabulary in scenarios
@jeremy jeremy mentioned this pull request Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants