Skip to content

feat: sessions and files namespaces (3/5) - #10

Merged
bdchatham merged 15 commits into
mainfrom
feat/alignment-03-sessions
Aug 21, 2026
Merged

feat: sessions and files namespaces (3/5)#10
bdchatham merged 15 commits into
mainfrom
feat/alignment-03-sessions

Conversation

@bdchatham

Copy link
Copy Markdown
Collaborator

Milestone 3 of specs/002-upstream-alignment. Adds the two namespaces that
mirror upstream's client.sessions and client.files.for_session, so the Go
surface reads the way the Python client does.

Stacked on #8. Review that first; this diff is against its branch.

Shape

client.Sessions().Create(ctx, req)
client.Sessions().ListItems(ctx, id, opts)      // iter.Seq2, follows the cursor
client.Files().ForSession(id).Upload(ctx, name, r)

Files is split from Sessions rather than hung off it, matching upstream: three
of its operations do not need a session id, and forcing one on them was wrong.

  • 28 schema-derived types in session_types.go, plus contracts.go for the
    routes the description does not declare.
  • Listings are iter.Seq2 over a pager that refuses to run forever: a page
    ceiling and a repeated-cursor check, both named in the error.
  • Sessions.ChildrenTree walks a subtree under three caller-set bounds
    (depth, in-flight requests, node count) and reports why it stopped per node.

Decisions worth a look

ConversationItem.Data is json.RawMessage. The description gives the item
payload 11 $refs and no discriminator, so a variant map cannot be pinned
without guessing. Raw bytes are honest; a sealed interface would not be.

There is no UnbindRunner. The description says a nil runner_id leaves the
binding unchanged and defines no value that releases one. Noted in the source
next to BindRunner so the absence reads as a decision, not an oversight.

SessionFile keeps its raw body. Every file route publishes an empty
response schema, so the named fields are observed rather than guaranteed and
Raw is the escape hatch.

Fixed here, each with a measurement

Defect Before After
Pager could not terminate 26,094 requests in 2s 3, named error
Upload leaked a goroutine 3 rejected, 3 blocked 0
Walk exceeded its bound 28 in flight vs 4 exactly 4
Download overran the sink 11 bytes to a 10-byte writer never exceeds
Walk depended on response order 60ms flipped busy deterministic
Transfers on the unary budget upload died at 4,267/10,240 bytes completes
Server strings unsanitized 40KB, 2-line error 450 bytes, 1 line
Filename forged a MIME header X-Injected landed rejected
Upload 307 unclassified bare 307 either way unsafe vs not-followed

Every gate is mutation-proven, with the mutation asserted as landed.

Verification

bin/check.sh rc=0 · -race -count=2 clean · golangci-lint 0 issues ·
103 tests · 88.6% coverage · zero module dependencies.

Known gaps

  • The subtree walk still starts one goroutine per node. MaxNodes bounds it;
    a worker pool waits for a tree with ~200 children at one level.
  • The fairness win from per-request gating has no regression test — the property
    is a scheduling race, so a timing assertion passes on a lucky schedule. The
    mechanism (gateFetch) is tested; the emergent behaviour is measured only.

bdchatham and others added 13 commits August 19, 2026 19:58
First slice of milestone 3. Types only: the 28 schemas the session, agent and
file routes reach, derived from the description and brought under the conformance
gate. No methods yet.

The gate earned its keep immediately. Adding these types surfaced 14 enumerated
fields across the mapped set, where enums.go named only 6 — including
MessageDataRoleAssistant, which the driver's production code compares against.
enums.go now names all 49 declared values.

Three roots the routes need are absent from the description. SessionCreateRequest
is hand-written because the create route takes a raw body and dispatches on
Content-Type, so FastAPI emits no requestBody for it. SessionFile and RunnerInfo
are hand-written because those routes declare no response shape at all: GET,
POST and DELETE on the session files collection all publish an empty schema, and
GET /v1/runners publishes a free-form map of arrays of untyped objects.

That means the whole file surface sits outside the conformance gate, in both
directions. Upstream has the same problem and solves it the same way — its
_files.py converts a raw dict by hand. doc.go names each such contract, and the
methods that reach them land in the next slice.
TestEveryMirroredTypeIsMapped checked reachable-implies-mapped. Nothing checked
the inverse, so an entry in schemaFor could name a schema for a type no root
walks, and the field, type and enum checks would all skip it in silence. Review
named this gap after two dead entries sat there unnoticed; adding the session
types re-created it at scale — 28 mapped and 0 of them inspected.

mirroredTypes derived its roots from eventRegistry alone, which reaches only what
the stream decodes. The request and response surface needs its own roots. Eleven
of them are the ConversationItem.Data union: the description declares an
eleven-variant anyOf, Go has no sum type, so the field decodes as any and a
caller unmarshals into one of these after switching on the item type. Nothing
else reaches them.

Coverage goes from 65 types and 271 fields to 93 and 491, which is every entry
schemaFor names. Verified by removing one root and confirming the new test fails
on it.
Three blinded reviewers. Idiom and prose dissented, structure ratified.

The gate had a hole exactly where this PR made its choices. goKindFor mapped
"object" to reflect.Map and stopped, so map[string]any satisfied a schema
declaring additionalProperties as a $ref or a typed value. Ten fields sat in that
blind spot, nine of them new here. The type test now descends one level into
additionalProperties and items, and the ten are typed: Labels and LastTaskError
and Headers to map[string]string, MCPStartup to map[string]MCPServerStartup,
UsageByModel to map[string]ModelUsage. Extending the gate first meant the fixes
landed enforced rather than merely applied.

Two exported names contradicted the package's own initialism table, which lives
in code at conformance_test.go: LlmModel where goFieldName returns LLMModel, and
TotalCostUsd where the same wire key is already TotalCostUSD three times over. My
earlier initialism sweep ran before this file existed, so it found nothing.

ConversationItem.Data was any. The description gives it eleven $refs and no
discriminator, so a sealed interface like Event would need a variant map no test
could pin — the category doc.go warns about. It is json.RawMessage instead, the
shape APIError.Detail already sets, and the variant list and switch key now sit on
the field rather than only in a test comment.

UpdateSessionRequest.TerminalLaunchArgs carried omitempty, which drops an empty
slice, so a caller could not send the [] the description defines as "replace
wholesale" — and the stored value in the example is a permission-skipping flag.
Without omitempty, nil marshals to null (leave unchanged) and []string{} to []
(clear). project_id deliberately keeps omitempty: an explicit null is a 400 there.

Thirteen pointer fields documented a value unconditionally — Deleted *bool as
"Always true." on the only confirmation field of a delete. Each now leads with
when it is nil. PaginatedList.Data justified itself with "list is invariant",
which is a statement about Python, and named two types this package does not
declare.

Five of fourteen enum doc comments ended mid-sentence and two split a hyphenated
word across a line break, which go doc publishes as "sub- agent". The generator
truncated at three wrapped lines regardless of where the sentence ended; it now
takes whole sentences and never breaks inside a hyphenated token. A missing blank
line had also fused the file comment onto the first const block, scoping the
switch-safety rule to two constants out of forty-nine; that rationale now lives in
doc.go where a reader is already looking.

NOTICE named types.go and event.go while session_types.go reproduces 144 upstream
descriptions and enums.go nine. AGENTS.md had said that list was "exhaustive by
measurement, not by convention. Nothing checks it for you" — and the next commit
proved it. TestNoticeNamesEveryFileCarryingUpstreamProse is the something that
checks it, in both directions.

doc.go claimed there is no session surface, and both doc.go and AGENTS.md still
said four conformance tests when there are six over five dimensions. The
conformance header listed three of the five.

Structure, from the brandon-code pass: freeFormFields was an escape hatch with no
entries and no caller, so it is gone and its knowledge sits on goKindFor.
TestEveryUnionMemberDecodes decodes nothing and is now
TestEveryUnionMemberIsRegistered. The two field tests had drifted on the tag rule
— one guarded an empty name, the other did not — so both call one wireName.
Dropped one history sentence and one count that was already stale.
The session surface as a namespace, matching upstream: Client.Sessions() rather
than twenty-odd more methods on Client. Create, Get, Delete, Fork, Update, the six
named patch wrappers, PostEvent and its SendMessage/Interrupt/Compact wrappers,
ResolveElicitation, and four listings.

Every listing pages internally and yields one sequence, so a caller ranges once
instead of writing the same cursor loop. pageSeq stops on an empty cursor as well
as on has_more, because a proxy that reports more while returning nothing would
otherwise loop without end. It starts no goroutine, so abandoning the range issues
no further request — tested.

Three things the description corrected, each of which my own plan had wrong.
Clearing a model override or a reasoning effort uses an alias the server reads,
not the empty string, so SetModelOverride now rejects "" and points at
ClearModelOverride. And there is no UnbindRunner: the description says only that a
nil runner_id leaves the binding unchanged and defines no value that releases one,
so the method is absent with a comment saying why rather than shipped on a guess.

ChildrenTree bounds three ways, each for a failure it would otherwise hit: depth,
because a caller cannot know a server-shaped tree's depth; concurrency, because
one listing per child per level is a request storm; and a visited set, because a
tree carrying a cycle never ends. A repeat is recorded as a truncated leaf rather
than followed, so the topology stays honest. Every child pages, which is where
upstream's own walk stops short. Bounded with a channel semaphore rather than
errgroup, because the module has no dependencies and keeps none.

SubtreeBusy reports an error rather than a false negative when the walk truncated:
a quiet answer from a walk that stopped early is not evidence of a quiet subtree.
A positive answer is returned as-is, because it is sound whatever the walk missed.

Files is session-scoped only. Upstream keeps a flat half — files.get(file_id) and
friends — but every one of those raises: /v1/files was removed server-side, and
the description carries no non-session file route. Porting a method that cannot
work would be porting a tombstone.

Upload takes an io.Reader and streams through a pipe, so memory does not track
file size — measured at 32 MiB. The writing goroutine closes the pipe on every
path, so a failing reader surfaces as an error rather than a hang. Download
requires an explicit byte bound and reads one byte past it, because stopping
exactly at the limit cannot tell a file that fits from one that was truncated.

doc.go now names the four hand-written contracts this milestone reaches and where
each is used, since the file surface sits wholly outside the conformance gate.
The last two of upstream's session methods, and the tests the surface was short of.

ResolveAgent closes the gap between what a person picks and what session creation
binds: a picker lists names, Create takes an id. It follows the listing cursor, so
an agent past the first page resolves. A miss wraps ErrNotFound — no 404 was
involved, the listing succeeded and carried no such name — and names some of the
names it did see, capped at fifty, because a large deployment should not build
thousands to render one error.

ResolveOnlineRunner finds a runner that can drive a harness. A runner reporting no
harness list is the fallback, taken only when nothing advertises the harness
outright: the server matches the same way, and treating silence as a refusal
leaves a usable runner idle. Canonicalize is a function rather than a table
because the aliases are the server's, and a table here would be a second copy
going stale. No match is an empty id and a nil error, because that is a normal
state a caller waits on rather than a failure.

RunnerInfo is hand-written. GET /v1/runners publishes a map of arrays of untyped
objects, so nothing pins these fields and a rename breaks the resolve silently.

The new tests target what a hand-written client actually gets wrong. A conformance
gate cannot see a wrong route or a wrong verb, because neither is a type, so
eighteen table cases assert method, path and body for every call — including that
BindRunner patches rather than posts, and that Interrupt and Compact are inputs on
the events route rather than routes of their own. Options are asserted at the
wire, not on the struct, since an option that never reaches a header is the
failure worth catching. And the base-URL redaction is tested on both paths where a
password can hide, including url.Parse's own error, which quotes its input.

Coverage 71% to 87.1%. Excluding the 53 sealed-interface markers — one statement
each, which no honest test calls — every one of the 98 remaining functions has
coverage. Reporting it both ways because the marker count moves the headline
number without moving what is tested.
Findings from four blinded reviewers. The two unbounded-resource defects were
reachable today and neither the suite nor 89% coverage on ChildrenTree saw them,
because the tests exercise statements and the requirements are about properties.

A listing never ended. pageSeq stopped on has_more and on an empty cursor, and on
nothing the server could not control: a listing returning a cursor it had already
returned paged forever. Measured at 26,094 requests in two seconds, with
ChildrenTree buffering every row before its visited set could dedup, so the heap
grew with them. It now stops on a repeated cursor and at a page ceiling, and says
which cursor repeated. The comment claiming the old pair made the walk "terminate
on its own rather than on the server's good behaviour" was false; it closed one
case of three.

Upload leaked its writer goroutine. The goroutine starts before doUpload validates
anything, so a rejection that never builds a request left nothing to close the
pipe and the goroutine blocked writing the multipart header forever — reachable
through a session id the request path rejects. Measured: three rejected uploads,
three permanently blocked goroutines. One deferred Close on the read half fixes
it. The invariant comment named the wrong side of the pipe: the request side was
never the side that hangs.

The concurrency bound skipped the widest level of the walk. The depth-cap probe
issued a listing per child without taking a token, and that level has more
requests than every other combined. A bound of 4 admitted 28 in flight, measured.
It now takes the same token as a descending listing.

Download wrote one byte past the caller's bound. Reading one past is how a file
that fits is told from one truncated, but the byte travelled through the caller's
writer rather than a probe, and the returned count was clamped so it disagreed
with what the writer received. It now copies exactly maxBytes and probes the body
separately. The old test passed throughout, because it asserted the clamped return
value and never looked at the sink; it is replaced by one that checks both.

Two sentinels replace wrong ones. ErrTruncated for a response past the caller's
bound, and ErrListingUnbounded for a listing that never ends — both were
ErrInvalidArgument or stream vocabulary, and doc.go defines ErrInvalidArgument as
meaning this package rejected the call before sending anything. Neither case did.

SubtreeBusy reports completeness as a value rather than an error. Truncation is a
property of the answer, not a failure: a caller raises MaxDepth or accepts the
bound, and forcing that through an error made the documented zero-value call fail
on any tree deeper than three. It also now says plainly that it answers about the
subtree below the named session, because no listing describes the named session
itself and its state was never read.

SessionFile.Raw is populated. It was documented in two places as the escape hatch
for the one surface with no schema, and nothing filled it, so a caller reaching
for an unnamed server field got nil. UnmarshalJSON now keeps the body.

Also names recordErr in the walk, where the first-error-wins dance was written
twice.
…rder

The visited set was global, so a session reachable through two parents was claimed
by whichever goroutine won the race, and the claiming node's depth then decided
how much of that subtree the depth cap admitted. Review measured the same fixture
answering (true, nil) and (false, truncated) with 60ms of added latency between
runs. A busy signal that changes with network jitter cannot drive control flow.

The set is now per path, copied on descent. A path cannot race with itself, so the
answer follows the tree and nothing else. Cycles are still caught, because a cycle
repeats within a path. The cost is that a session reachable two ways is expanded
under both, which is true to the topology and which MaxNodes bounds.

MaxNodes is that bound, defaulting to a thousand. The walk spawns one goroutine
per node, so before this the tree's shape decided the process's memory: review
measured 512 goroutines at Concurrency 2, and branching 500 at depth 3 wants 125
million. Measured now: a width-500 tree at depth 3 peaks at about a thousand
goroutines and reports the rest as truncated rather than attempting them. The
goroutine-per-node shape remains; a worker pool draining a node queue would hold
the same request profile at a fixed count, and that is a larger change than this.

TreeNode.Truncated was answering three questions at once. A node with no children
now says which of four reasons applies: it has none, the walk stopped at a bound,
the session already appears on the path above, or its own listing failed. Err is
new and closes the finding that an unreadable subtree was indistinguishable from
an empty one — a UI rendered a failure as "no children".

SubtreeBusy accounts for those separately. Truncated and Err make the answer
incomplete; Repeated does not, because a repeated session's subtree was already
walked where it first appeared.

The determinism test needed two attempts. The first fixture put both parents at
depth 1, which is symmetric, so it passed against the defect it was written to
catch. The reachability has to be asymmetric — depth 1 through one parent and
depth 2 through the other — for the claiming node's depth to matter. Verified by
restoring the shared set and watching the shallow-path case report false.
Splitting TreeNode.Truncated into Truncated, Repeated and Err left this test
asserting the old overloaded meaning, so it failed on the commit that split them.
It now checks Repeated and that Truncated is absent, which is the distinction the
split exists to make.

The commit that split the field pushed with the suite red. check.sh reported it
and I committed in the same step rather than gating on it.
Four deferred findings whose stated un-defer conditions are now met, plus the
sanitizer coverage this package already required of itself.

Sanitize every server-chosen field. sanitizeForError existed and two new call
sites did not use it: APIError.Code (rendered when Title is absent) and
APIError.RequestID both reached the message raw, and ResolveAgent rendered
server-chosen agent names unbounded. Measured before: a 400 with a CRLF in Code
forged a second log line, and a 1000-agent listing produced a 40KB two-line
error. After: one line, 450 bytes, truncation reported. Adds maxRequestIDRunes
and maxAgentNameRunes, and caps the rendered name list by total length rather
than count alone.

Reject a filename that would forge a multipart header. mime/multipart escapes a
quote and a backslash and passes CR and LF through, so a CR in a filename ends
the Content-Disposition line and what follows is read as another header or part.
Proven against mime/multipart directly. Callers derive filenames from a model,
so this is reachable input.

Give a transfer its own budget. doUpload and doDownload charged a whole file to
the 90-second unary timeout, so a large upload could not succeed at any link
speed and a download truncated mid-body. Measured against a 200ms budget: the
upload died with 4267 of 10240 bytes sent and the download at 5120 of 10240,
both while making steady progress. Transfers now run on a third http.Client with
no whole-exchange bound; the context is the limit, and WithTransferTimeout sets
one ceiling for callers who want it. Unary calls keep theirs.

Gate the subtree walk per request, not per listing. Concurrency bounded
listings, so a node paging many times held a slot for its whole drain and a
branch whose levels are sequential round trips waited behind it: a fixture with
four multi-page nodes and a chain took 770ms against a 500ms critical path, and
576ms after. gateFetch carries the contract and honours ctx while waiting.

Classify a redirect an upload cannot follow. net/http follows a 307 or 308 by
replaying the request, and a streamed body cannot be replayed, so it returns the
response and checkRedirect never runs. An upload reported a bare 307 for both a
hostile location and a same-host path rewrite — the second being what a
path-rewriting proxy does. checkRedirect's own gates now decide which:
ErrUnsafeRedirect off-host, ErrRedirectNotFollowed on-host with the one fix
named. The credential still never travels.

Also: reattach checkRedirect's doc comment, which was fused onto
stripRedirectURL and left the redirect policy undocumented; correct
ChildrenTree's "visits each session once", which the per-path visited set
reversed; and replace the upload heap-growth test with a rendezvous. That test
read a process-wide heap while running in parallel, so its verdict depended on
what else the suite was doing, and it tipped once here. The rendezvous is
deterministic: the reader withholds its second chunk until the server has read
part of the first, which a buffering implementation cannot satisfy.

Every gate is mutation-proven, with the mutation asserted as landed. Deferred:
the goroutine-per-node fan-out, which MaxNodes bounds; un-defer at the first
tenant with more than roughly 200 children at one level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review round's remaining merge conditions, each found by scanning rather
than from a list.

Reattach and retarget the links. checkRedirect's twenty-line security rationale
was fused onto stripRedirectURL, so the redirect policy itself was undocumented.
Six links named nothing: two constructors that do not exist, and four methods
attributed to Client when they are on Sessions. TestEveryDocLinkResolves now
fails the suite on either shape, and checks a member against the member rather
than accepting its owning type — the wrong-owner link is the costly one, because
it reads as authoritative. Its two skip rules are structural, so it needs no
allowlist to rot.

Delete an orphaned doc comment. list.go carried thirteen lines describing
ListAgents as returning one page and saying no lookup-by-name route exists. It
attached to nothing, and both claims had become false: the method walks every
page, and ResolveAgent is the lookup. What was still true moved onto the real
method, where go doc publishes it.

Attach the rest, or stop repeating them. Four more blocks rendered nowhere: the
patch-wrapper rationale now sits on Update, and "there is no UnbindRunner" on
BindRunner, which is where a reader goes looking for it. The two that restated
doc.go are now three lines pointing at the section that owns the policy — and
the section names were checked, because a pointer that dangles is worse than the
duplication.

Correct doc.go. Scope still said no method reaches the session routes, which
PR 3's twenty-seven methods contradict. Timeouts said two clients, which the
transfer client makes three. The optional-field rule claimed every optional
field is a pointer; eighteen are not, and eleven of those are request fields
where omitempty on a plain value is the right idiom. The rule now says which
direction it governs, so the next reader does not "fix" the eleven.

Unexport TerminalTaskStatuses. An exported slice is mutable by every importer,
and appending to it would change what SubtreeBusy reports for every caller in
the process. IsTerminalTaskStatus was already the way to ask.

Make SessionFile's optional fields pointers, as this package's own rule
requires. Bytes conflated a zero-length file with one the server did not
measure, which its own comment admitted.

Also: drop history narration from two comments, and replace AGENTS.md's "nothing
checks it for you" about NOTICE — something does, and the rule now names the test
instead of restating a list that had already gone stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up PR 2's removal of docs/adr/0001-rebuild-rather-than-reland.md, which
landed after this branch started.

doc.go conflicted because both sides rewrote the Scope paragraph: PR 2 dropped
the ADR pointer, this branch replaced "there is no session, files ... surface"
with what the two namespaces now carry. Resolved by keeping this branch's text
without the pointer, since the file it named is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up the Go floor bump to 1.25.0, the Event interface change, the lint-job
toolchain pin, and PR 2's own docs corrections.

Five conflicts, resolved on the merits rather than by side:

- NOTICE and the conformance-test count: this branch's numbers are the accurate
  ones, because it adds session_types.go and a sixth conformance test.
- doc.go's Cancellation note: PR 2 said this release cannot post an interrupt,
  which was true there. Sessions.Interrupt exists here, so the pointer stands.
- attribution_test.go and doc_links_test.go: both branches added them. Took PR
  2's copies — its attribution doc states the present rather than narrating the
  change that prompted it, and its doc-link example names no type, so it stays
  true in either branch.
- AGENTS.md's NOTICE rule: kept PR 2's "exhaustive by measurement, not by
  convention" framing with this branch's note on what failing looks like.

Also applies t.Context() to the 59 sites added on this branch, so the convention
holds across the whole suite rather than only the files PR 2 touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham changed the base branch from feat/alignment-02-foundation to main August 20, 2026 17:58
@bdchatham

Copy link
Copy Markdown
Collaborator Author

Reviewer note: the wire types are likely to become generated

A spike after this PR was opened showed that oapi-codegen can produce these
types from spec/openapi.json, given a ~120-line pre-process stage that stamps
the Go type decisions the document cannot carry. Measured against upstream
9d54826e: ten of ten types match field for field, type for type, and tag for
tag
, 53 of 53 event variants generate, and the facade compiles and vets clean
on top.

Two consequences for reviewing this PR.

Review the shape, not the authorship. Because the generated types are
identical, a review of session_types.go's fields, types, and JSON tags carries
over intact. Only where the types come from would change. So field-level scrutiny
here is not wasted, and it is the part worth your time.

schemaFor in conformance_test.go is superseded, not defended. The 94-entry
table exists so the conformance test can find each type's spec schema. Generated
types do not need a table asserting they mirror the schema they came from — the
table and its test both go. Please skip that debate rather than resolving it here.

The proposal is written up as an ADR at status Proposed, with the
measurements and the costs:
docs/adr/0001-generate-wire-types-behind-a-facade.md.

Deliberately not in this PR. Generation deletes event.go, which this PR does
not touch, and it would force a rebase of #11's turn-loop commits for no review
benefit. It is also a one-way door under AGENTS.md, so it needs its own approval
rather than arriving inside "sessions and files namespaces". Because the public
surface does not move, it can land later as a patch.

One related fact worth knowing while reviewing: the vendored spec is one event
variant behind upstream (SessionTitleEvent), and the suite is green. That gap is
the case for generation, and it is not addressed here.

@bdchatham bdchatham changed the title feat: sessions and files namespaces (3/4) feat: sessions and files namespaces (3/5) Aug 21, 2026
bdchatham and others added 2 commits August 21, 2026 07:52
schemaFor held 94 rows and 87 of them said a name equals itself. Those rows
carried no information, and every rename had to touch two places to stay true.

A Go name is its schema name. Seven types differ: two because Go writes an
initialism in capitals, five because this package prefixes a response event the
description leaves bare, so ResponseCompletedEvent does not collide with
TurnCompletedEvent. Those seven are the whole table now.

TestEveryMappedTypeIsReachable and TestEveryMirroredTypeIsMapped go with it. Both
existed to police the hand-maintained list in each direction, and a name that
defaults to itself cannot disagree with the set of names. What they were really
guarding is unchanged: declaredProperties fails when a type resolves to a schema
the description does not declare, and surfaceRoots still decides what gets
walked at all.

Verified in both directions: an exception pointing at a schema that does not
exist fails, and removing a needed exception fails with `spec declares no schema
"ResponseCompletedEvent"`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three passages named a count and a dimension the previous commit removed. The
mapping-complete-in-both-directions dimension went with the two tests that
policed the hand-maintained table, so "five dimensions" described four and "six
tests" described three.

Each now states the list and no number. A count in prose goes stale the first
time a test is split or retired, and it went stale twice while this branch was
open.

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

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier note: schemaFor is fixed here, not deferred

Above I said the 94-row schemaFor table was "superseded, not defended" and asked reviewers to skip that debate. That was the wrong call — the table was worth fixing in this PR, and it did not need generation to fix.

It is now 7 rows, and each one carries information:

// Two differ because Go writes an initialism in capitals and the description
// does not. Five differ because this package prefixes a response event the
// description leaves bare, so ResponseCompletedEvent does not collide with
// TurnCompletedEvent.
var schemaExceptions = map[string]string{ ... }

func schemaFor(goName string) string  // exception, else the name itself

A Go name is its schema name, so only the exceptions are listed. The other 87 rows said a name equals itself, carried nothing, and had to be touched on every rename.

Two tests went with it. TestEveryMappedTypeIsReachable and TestEveryMirroredTypeIsMapped existed only to police the hand-maintained list in each direction. A name that defaults to itself cannot disagree with the set of names, so both became unfailable. What they were really guarding is unchanged: declaredProperties fails when a type resolves to a schema the description does not declare, and surfaceRoots still decides what gets walked at all.

Verified in both directions rather than by reading:

  • an exception pointing at a schema that does not exist → spec declares no schema "NoSuchSchema"
  • removing a needed exception → spec declares no schema "ResponseCompletedEvent"

Also: counts in prose, removed

Three passages named a test count or a dimension list that the change invalidated — AGENTS.md, doc.go, and conformance_test.go's header all claimed more than existed. The "mapping is complete in both directions" dimension went with the two deleted tests.

Each now states the list and no number. The counts went stale twice while this branch was open, which is the argument against having them.

bin/check.sh    all five legs green
golangci-lint   0 issues

For context on where this lands: #14 replaces the 7 exceptions with a derivation, leaving 2 rows. So the arc is 94 → 7 → 2, and each step stands on its own.

@bdchatham
bdchatham requested a review from masih August 21, 2026 17:04
@bdchatham
bdchatham merged commit bd629cc into main Aug 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants