Skip to content

Stop the sparse document PUT from erasing the content (#543) - #601

Merged
jeremy merged 9 commits into
mainfrom
feat/documents-triad
Aug 3, 2026
Merged

Stop the sparse document PUT from erasing the content (#543)#601
jeremy merged 9 commits into
mainfrom
feat/documents-triad

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #543. First child of #374, and the template #547 (ScheduleEntries) copies — the shape established here is the one that wave repeats, so it is written to be copied rather than to be clever.

Was stacked on #593; that merged as 9459083e while this was in flight, so this is rebased onto main and targets main directly. After that rebase this PR carries no repin of its own — it depends on the repin already on main from #593, which is what makes it safe to ship (see below).

PUT /{accountId}/documents/{documentId} is a full replace, and every SDK shipped a sparse update over it. UpdateDocument becomes ReplaceDocument — breaking, no deprecated alias, the ReplaceTodo precedent (#375) — and the merge-safe update / edit / raw replace triad lands in all six SDKs.

Red proof — what the shipped update puts on the wire

One call, update(title: "Q3 Plan"). It never mentions content. Captured against the base branch and against this one; literal output, pasted from the capture files.

Python

--- BASE (feat/folders-and-repin, shipped generated update) ---
  PUT  /999/documents/456  body={"title": "Q3 Plan"}
        -> content ABSENT: BC3 rebuilds the recordable from the permitted params, so this ERASES the document body

--- THIS BRANCH (merge-safe composite update) ---
  GET  /999/documents/456
  PUT  /999/documents/456  body={"content": "<div>Milestones and owners.</div>", "title": "Q3 Plan"}
        -> content preserved: '<div>Milestones and owners.</div>'

Ruby

--- BASE (feat/folders-and-repin, shipped generated update) ---
  PUT  /999/documents/456  body={"title":"Q3 Plan"}
        -> content ABSENT: BC3 rebuilds the recordable from the permitted params, so this ERASES the document body

--- THIS BRANCH (merge-safe composite update) ---
  GET  /999/documents/456
  PUT  /999/documents/456  body={"content":"<div>Milestones and owners.</div>","title":"Q3 Plan"}
        -> content preserved: "<div>Milestones and owners.</div>"

Go — worth its own run, because Go's Update was a hand-written wrapper in vaults.go, not the generated shape the other five shared:

--- BASE (feat/folders-and-repin, shipped hand-written Update in vaults.go) ---
  PUT  /999/documents/456  body={"title":"Q3 Plan"}
        -> content ABSENT: BC3 rebuilds the recordable from the permitted params, so this ERASES the document body

--- THIS BRANCH (merge-safe composite Update in documents.go) ---
  GET  /999/documents/456
  PUT  /999/documents/456  body={"content":"<div>Milestones and owners.</div>","title":"Q3 Plan"}
        -> content preserved: "<div>Milestones and owners.</div>"

(Go's line is reproduced above with the HTML unescaped for readability; the raw capture prints Go's <div> escaping of the same string. Kotlin, Swift and TypeScript shipped the same generated sparse body — body.title?.let { put(...) } and its analogues — so the two structurally distinct paths are the generated one and Go's, and both are shown.)

Red proof — the tests

Every new test was run against deliberately un-fixed source, in a scratch copy of the tree rather than by mutating the tracked file:

proof failures against un-fixed code
Python, serializer compacted to drop empties AssertionError: assert 'content' in {'title': 'Project Overview'}3 failed
Python, guards removed (naive document.get(key) or "") Failed: DID NOT RAISE <class 'basecamp.errors.ApiError'> / AttributeError: 'NoneType' object has no attribute 'get'29 failed, 4 passed
TypeScript, putFields with || undefined AssertionError: expected { title: 'Project Overview' } to have property "content"2 failed
TypeScript, guards removed (naive ?? read) expected the call to reject, but it resolved / AssertionError: expected TypeError: Cannot read properties of null… to be an instance of BasecampError25 failed, 4 passed

That last line is the point of requireRecord: un-guarded, a null envelope degrades to a raw TypeError instead of the documented statusless api_error.

The measured contract: title and content are BOTH optional

This is the one place Documents diverges from Todos and Todolists, and the reason it is stated rather than inferred — modelling either field as @required would make the SDK reject a request the server accepts.

claim evidence
the PUT is a full replace DocumentsController#update@recording.update! recording_attributes.merge(recordable: new_document), where new_document is Document.new(params.require(:document).permit(:title, :content)) — a brand-new recordable from the permitted params, swapped in wholesale, so an absent field is nil on the replacement
omitting title is a 200, not a 422 Document#title is super.presence || "Untitled" (app/models/document.rb:7-9); the model declares no validates, and neither Recordable nor Recording presence-validates the recordable's title
omitting content is a 200 that clears same rebuild; no validation on content either
a body with neither field is a 400 params.require(:document) raises ActionController::ParameterMissing; Rails wrap_parameters only synthesizes the wrapper from a flat body carrying at least one Document attribute name. Pinned upstream by test "publishing a draft document requires the full payload and preserves it" (test/api/documents_controller_api_test.rb:56)

The public API docs say it outright: "A status-only update fails with 400 Bad Request (the document parameters are required), and omitting a field clears its value."

Read-side, the asymmetry inverts

Document.title is @required on the response schema and BC3 can never render it blank, so an absent or null title in a 2xx body is a malformed response, not an empty title — coalescing it to "" and sending that in the full-replace PUT would blank the real title on a call that only touched content. All six refuse it: Kotlin and Swift get it from the decoder (non-optional String), and Go, Python, Ruby and TypeScript check explicitly, because their reads would otherwise yield the string zero value. content is optional on the response schema, so absent there is genuinely empty.

Request optionality and response requiredness are separate facts, modelled separately. Getting that backwards in either direction is a bug: @required on the input would reject a request the server accepts; a tolerant read would write a blank title nobody asked for.

So ReplaceDocumentInput marks neither field @required. Go's Replace refuses a body with neither locally rather than spending a round-trip on the 400; the other five leave it to the server.

Why this was blocked, and why it no longer is

A merge-safe composite PUTs a full representation and names neither subscriptions nor notify. BC3's notify_param defaulted to "custom", so find_subscribers ran where(id: params[:subscriptions])where(id: nil) → empty, and every sparse update to a drafted recording reset its subscriber list to creator + updater. The list is also unreadable over the API — only subscription_url is emitted — so the composite could not have preserved it by resending. Shipping before that fix would have preserved title and content while silently unsubscribing everyone.

Fixed in bc3 #12494 (344581a379) and #12501 (2c0dafba13) by Recording::DraftSubscribers, whose predicate is params.key?(:subscriptions) || params.key?(:notify): a request addressing neither keeps the list it found. #593's repin to 2c0dafba13 is what brings that into the SDK's provenance, and it is already on main — this PR reads that provenance rather than moving it, and spec/api-provenance.json is untouched here.

One correction to #543's body: the concern shipped as Recording::DraftSubscribers (app/controllers/concerns/recording/draft_subscribers.rb), not Recording::DraftSubscriberParams.

The overlay does not reintroduce #576

The merge-safe read is the exact defect class #597 is fixing on Todos and Cards: a value read off the GET is a value written by the PUT, on a call that never mentioned the field. Documents reads two string fields, so it uses #597's shared guards rather than a fourth private copyrequire_mapping/writable_string, requireRecord/writableString, MergeSafe.require_hash/MergeSafe.writable_string. Go, Kotlin and Swift decode into typed models and need none (Kotlin's client-wide coerceInputValues scalar hole is the known cross-service gap from #597, not something a composite can close).

#597 has not merged yet, so python/src/basecamp/services/_merge_safe.py, ruby/lib/basecamp/services/merge_safe.rb and typescript/src/services/merge-safe.ts appear here as byte-identical copies — verified by shasum -a 256 against fix/composite-containment-576. When #597 lands they rebase to a zero diff. They are not forked and must not be edited here; #543 owns only the Documents call sites.

Deliberately not carried over: a Documents kill fixture. errorRaised and the six runner branches that support it are #597's, and duplicating them — including the Swift requiresHTTPSCrashProbe defect still open on that PR — would be worse than waiting. The guard is covered by native tests in all three languages here, and the shared kill case lands as a follow-up once errorRaised is on main.

Also out of scope, matching #597 exactly: the caller-side mirror. edit hands back a mutable view and a closure assigning 42 walks into the same PUT — but that is a value the caller chose, not one silently substituted for a value they asked to preserve. Ruby normalises nil"" rather than to_s, so nothing is coerced on the way out.

Surface

SDK placement
Go new go/pkg/basecamp/documents.goUpdate / Edit / Replace + private replaceDocument owning the hook envelope and the single ReplaceDocumentWithBodyWithResponse call site (SPEC §18 rule 1 carve-out: omitempty cannot express always-send-empty). Read/create/trash stay in vaults.go
TypeScript src/services/documents-extensions.ts, import swapped in client.ts, split export in index.ts
Python src/basecamp/services/documents.py — sync + async, DocumentEdit / AsyncDocumentEdit context managers
Ruby lib/basecamp/services/documents_extensions.rb, prepended via a fourth on_load in lib/basecamp.rb
Kotlin Documents added to EXTENSIBLE_SERVICES and HAND_WRITTEN_SERVICES; services/DocumentsService.kt; generated-compat/UpdateDocumentBody.kt (the composite's body type, not a deprecated alias — null preserves where the generated body's null omits)
Swift Sources/Basecamp/DocumentsServiceExtensions.swift; the generated UpdateDocumentRequest.swift became ReplaceDocumentRequest.swift, freeing the name

Full-state serialization: both fields always sent, empties included. On a full-replace endpoint "" is how a clear is expressed — never JSON null (SPEC §18), and never by omission, which would hand the clear back to the server's own rebuild and read as an accident rather than an intent. Three independent attempts to "simplify" that into dropping empties were caught and reverted during this change; each one would have turned a stated clear back into the accident.

status: "active" (draft publish) stays unmodeled and is now said so in the operation doc: BC3 rejects a status-only update for the same reason it rejects an empty body, so publishing needs the full payload alongside it.

Review rounds (Codex, 13 x P2 + 1 CodeQL)

Eleven fixed, two carried to follow-ups, one declined with evidence — five rounds. The Go read-error handling took four passes, and the honest summary is that the first three were the wrong shape, not merely incomplete.

  • Go and Kotlin leaked raw decoder exceptions (*json.UnmarshalTypeError, SerializationException) where SPEC §6 wants a statusless non-retryable api_error. The PUT was correctly avoided either way, but a caller switching on *Error/BasecampException would have missed it — and Swift in this same PR already wrapped DecodingError, so the three decoder-backed SDKs disagreed with each other. Both now read through a private fetchDocument (dfa5e7205). Go's is deliberately narrow: a transport or HTTP error passes through untouched, so a 404 stays a 404. Red-proofed:

    --- FAIL: TestDocumentsService_UpdateWrapsADecodeFailureAsStatuslessAPIError (0.01s)
        --- FAIL: .../title_is_a_number (0.00s)
            documents_test.go:656: expected *Error, got *json.UnmarshalTypeError: json: cannot unmarshal number into Go struct field Document.title of type string
        --- FAIL: .../content_is_an_object (0.00s)
            documents_test.go:656: expected *Error, got *json.UnmarshalTypeError: json: cannot unmarshal object into Go struct field Document.content of type string
    
  • A missing required title on read — covered by the read-side section above; the guard shipped in all six before the review landed.

  • Go's Replace could not express {"title": "", "content": ""} — a legal full replacement that clears both. Zero-value guards collapsed it into the all-nil request, so the length check turned a legal clear into a usage error while the other five SDKs could express it. Both fields are now *string (d4cceb6fa); nil omits, a pointer to "" sends, and the all-nil request stays the usage error standing in for BC3's 400. This is the one Go request here that departs from the zero-value convention, and SPEC §5 says why.

  • The decode-error wrapper's allowlist was incomplete — twice (second review round). Document.created_at/updated_at are time.Time, whose UnmarshalJSON returns *time.ParseError, so the encoding/json allowlist leaked. Adding it does not close the class either: content_attachments carries *types.FlexInt dimensions and FlexInt.UnmarshalJSON rejects a non-integral value with a bare fmt.Errorf that is no named type at all. Against the three-type allowlist:

    --- FAIL: TestDocumentsService_UpdateWrapsADecodeFailureAsStatuslessAPIError (0.01s)
        --- FAIL: .../attachment_height_is_non-integral (0.00s)
            documents_test.go:683: expected *Error, got *errors.errorString: flexint: 1024.5 is not an integer
    

    28d6871da therefore classifies by exclusion: pass through an error already carrying the SDK taxonomy (checkResponse maps HTTP status), a transport failure, or a cancelled context; normalize everything else. An allowlist of decoder error types can only be extended, never completed. The test table spans all three shapes on purpose.

  • A blank title on read was accepted in five of six. Document#title is super.presence || "Untitled", so BC3 can never render one — "" on a 2xx read is a malformed response, not an empty title, and resending it blanks the real title on a call that only touched content. Go already refused it; the other five only covered absent and null. Kotlin and Swift needed it written by hand for the same reason: their non-optional String refuses absent/null at decode, but "" decodes fine (f7707c6b7).

  • A gating-hook error was misclassified as a decode failure. The classifier wrapped the whole of Get, which sits above the gate, so a circuit breaker's own sentinel came back as "does not decode" — breaking errors.Is and misreporting why nothing was sent:

    --- FAIL: TestDocumentsService_UpdatePreservesAGatingHookError (0.00s)
        documents_test.go:866: expected the gate's own error to survive, got *basecamp.Error: GetDocument returned a body that does not decode as a document: circuit breaker open
    

    Rather than teach the classifier to recognize an arbitrary sentinel, it moved below the gate, to the one call site whose only origins are the transport and the decoder. That removes the gate from the candidate set entirely, and fixes the same misclassification for direct Get callers too (f7707c6b7).

  • Classifying the Go read error by inspection could not be made to work, in either direction. The allowlist of decoder error types leaked *time.ParseError (from created_at) and then types.FlexInt's bare fmt.Errorf (from an attachment dimension). Inverting it to an exclusion list then leaked a gating hook's sentinel, and then an arbitrary AuthStrategy's — the auth editor runs per request inside the generated client, so it surfaces at the same call site with no HTTP response behind it. Neither set is enumerable.

    406d16943 stops guessing: DocumentsService.Get splits gen.GetDocument from generated.ParseGetDocumentResponse — exactly the two calls GetDocumentWithResponse makes — and normalizes the decode step only, so the origin is known by construction. Everything preflight returns verbatim and errors.Is keeps working. Both are public generated methods and the hook envelope is unchanged, so SPEC §18 rule 1 still holds. The service-drift gate only recognized .gen.*WithResponse, so 2e8c29fbf teaches it the two-stage form — verified still failing when a wire call really is missing:

    === ERROR: Generated operations NOT wrapped by service layer (1) ===
    ReplaceDocument
    
  • A whitespace-only title was accepted. Rails' presence delegates to blank?, so BC3 can no more render " " than "". All six now trim before the check.

  • The same auth-phase defect in Kotlin and Swift — split out as Isolate the response decoder from the auth/transport phase in Kotlin and Swift BaseService #604, not fixed here. Their AuthStrategy runs inside the request path, so a custom strategy throwing SerializationException/DecodingError while loading credentials is caught by the composite's decode catch. Go could be fixed locally because GetDocumentWithResponse is literally its two halves; Kotlin and Swift have no seam in the composite — the decode happens inside BaseService.request(info, block, parse), one level down and shared by all 243 operations. Wrapping parse there is the right fix, is a strict improvement for every service, and would let Todos, Todolists, Cards and Documents all drop their per-composite catches. That is a cross-cutting change to both SDKs' decode contract and belongs on its own. Residual: a mislabelled message in a scenario needing a custom auth strategy that throws a serialization error; no PUT is issued either way.

  • CodeQL java/class-name-matches-super-class — declined. It is the deliberate architecture of every extensible Kotlin service: the accessor must resolve to a subclass of the same name so callers need no import (SPEC §18 rule 5). Four identical alerts are already open on main for TodolistsService, UploadsService, TodosService and CardsService; this is the fifth instance of an accepted pattern, and if we want it gone it should be one repo-level decision covering all five.

SPEC

Conformance

conformance/tests/documents_write.jsonupdate-merge, edit-clear, replace-omission-clears, dispatched in all six runners (Go / Python / Ruby / TypeScript switches, Kotlin Main.kt, Swift Dispatch.swift). A fixture whose operation has no dispatch case silently skips, so each runner's PASS: lines were read back individually rather than trusting the totals.

Follow-ups filed

Copilot AI review requested due to automatic review settings August 3, 2026 08:41
@jeremy jeremy added the breaking Breaking change to public API label Aug 3, 2026
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin swift spec Changes to the Smithy spec or OpenAPI conformance Conformance test suite python Pull requests that update the Python SDK labels Aug 3, 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@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: 828cfe14bc

ℹ️ 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 typescript/src/services/documents-extensions.ts Outdated
Comment thread go/pkg/basecamp/documents.go
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/DocumentsService.kt Outdated
PUT /{accountId}/documents/{documentId} is a full replace. BC3's
DocumentsController#update runs

    @recording.update! recording_attributes.merge(recordable: new_document)

where new_document is Document.new(params.require(:document).permit(:title,
:content)) — a brand-new recordable built from only the permitted params and
swapped in wholesale. A field absent from the body is nil on the replacement.

The shipped surface was a typed partial update named `update`, so the natural
call — retitle a document, mention nothing else — put exactly {"title": ...}
on the wire and BC3 erased the body.

Rename the wire operation UpdateDocument -> ReplaceDocument (no deprecated
alias; the ReplaceTodo precedent, #375) and give every SDK the same three-method
surface: merge-safe `update` (GET, overlay the set fields, full PUT),
read-modify-write `edit`, and the verbatim `replace`.

title and content are BOTH optional, measured against bc3 rather than assumed.
Omitting title is a 200 and the title becomes "Untitled" (Document#title is
super.presence || "Untitled", app/models/document.rb:7-9, with no presence
validation); omitting content is a 200 that clears it. Neither is a 422, so
neither earns @required the way ReplaceTodo's content did. What BC3 does
require is the wrapping document object (params.require(:document), which
Rails wrap_parameters synthesizes from a flat body), so a body naming neither
field is a 400.

The composites read every writable field out of the GET and write all of them
back, so they carry #576's guards: a non-string is refused before the PUT, not
coalesced or forwarded. Go, Kotlin and Swift decode into a typed model first;
TypeScript, Python and Ruby have no runtime decoder and do it by hand.

Conformance documents_write.json covers update-merge, edit-clear and
replace-omission-clears, dispatched in all six runners.

Read-side, the two fields are not symmetric, and it is the inverse of the write
side. Document.title is @required on the RESPONSE schema and BC3 can never
render it blank, so an absent or null title in a 2xx body is a malformed
response rather than an empty title — coalescing it to "" and sending that in
the full-replace PUT would blank the real title on a call that only touched
content. All six refuse it: Kotlin and Swift get it from the decoder (non-
optional String), and Go, Python, Ruby and TypeScript check explicitly, since
their reads would otherwise yield the string zero value. content is optional on
the response schema, so absent there is genuinely empty. Request optionality
and response requiredness are separate facts and are modelled separately.
Copilot AI review requested due to automatic review settings August 3, 2026 09:13
@jeremy
jeremy force-pushed the feat/documents-triad branch from 828cfe1 to c83bcae Compare August 3, 2026 09:13

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@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: c83bcae267

ℹ️ 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/documents.go Outdated
Go and Kotlin both let a malformed 2xx body escape as a raw decoder exception —
*json.UnmarshalTypeError and kotlinx.serialization's SerializationException. The
PUT is correctly avoided either way, but SPEC §6 defines a shape for a malformed
successful response consumed by a composite: non-retryable, statusless
api_error, with a hint naming the deliberate-overwrite escape hatch. A caller
switching on *Error or catching BasecampException would miss the raw form.

The Swift composite in this series already wraps DecodingError. Route Go's and
Kotlin's reads through a private fetchDocument that does the same, so the three
decoder-backed SDKs agree with each other and with the hand-written guards the
other three carry.

Narrow by construction: Go inspects for *json.UnmarshalTypeError/*json.SyntaxError
and passes a transport or HTTP error through untouched, so a 404 still surfaces
as a 404 rather than as "does not decode".

Raised by Codex review on #601 (two P2s).
Copilot AI review requested due to automatic review settings August 3, 2026 09:24
Zero-value guards made one legal request unreachable from Go's raw path. BC3
rejects a body naming neither field with a 400 (params.require(:document)), but
a body naming both as "" is a legal full replacement that clears both. With
`Title string` + omitempty, those two collapse into the same struct value and
the length check turned the clear into a usage error — the other five SDKs can
express it, Go could not.

Both fields become *string: nil omits, a pointer to "" sends. The all-nil
request stays a usage error, which is the 400 it stands for.

Their server effect happens to coincide for title — an omitted title and an
empty one both read back as "Untitled" — but the SDK must not collapse a
distinction the wire makes, and content's does not coincide at all.

The conformance Go runner builds the request presence-aware, so an absent
fixture key stays absent on the wire.

Raised by Codex review on #601 (P2).

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings August 3, 2026 09:28

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Copilot errored out on all four attempts here ("Copilot encountered an error and was unable to review this pull request"), so its pass is missing rather than clean. Codex reviewed twice and CodeQL ran; all five findings are answered above — four fixed in `dfa5e7205` and `d4cceb6fa`, one declined with the four pre-existing main alerts that show it is an accepted pattern. Re-requesting Copilot once.

@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: d4cceb6fa9

ℹ️ 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/documents.go Outdated

@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: 90a4a73f54

ℹ️ 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/documents.go Outdated
Comment thread typescript/src/services/documents-extensions.ts Outdated
jeremy added 2 commits August 3, 2026 03:09
Two more review findings, and the first one retires the approach rather than
patching it again.

Deciding whether an error came from the response decoder by INSPECTING it does
not work in either direction. Decoder errors are not enumerable: created_at and
updated_at are time.Time, whose UnmarshalJSON returns *time.ParseError rather
than an encoding/json sentinel, and content_attachments carries *types.FlexInt
dimensions rejected with a plain fmt.Errorf that is no named type at all. The
errors that PRECEDE a response are not enumerable either: a gating hook, a token
provider, or a custom AuthStrategy may each return any sentinel they like, and
the auth editor runs per request inside the generated client.

So DocumentsService.Get splits GetDocument from ParseGetDocumentResponse —
exactly the two calls GetDocumentWithResponse makes — and calls the normalizer on
the decode step only. The origin is then known by construction. Everything
preflight returns verbatim, so errors.Is keeps working for a caller's own
sentinel.

Second: a blank title is malformed, not just an empty one. Document#title is
`super.presence || "Untitled"`, and Rails' presence treats a whitespace-only
string as blank — so the API can never render " " either, and resending it would
blank the real title. All six now trim before the check: strings.TrimSpace,
String.isBlank, trimmingCharacters, .trim(), .strip.empty?, and not .strip().

Raised by Codex review on #601 (three P2s, fourth round).
DocumentsService.Get now calls gen.GetDocument then
generated.ParseGetDocumentResponse instead of the combined
GetDocumentWithResponse, so it can tell a preflight or transport failure apart
from a malformed body. The drift check only looked for `.gen.*WithResponse` and
so reported GetDocument as unwrapped.

Count `generated.Parse<Op>Response` as a wrapper too. It names the operation
exactly, appears nowhere else, and the results are still deduped, so this
recognizes an equivalent form rather than relaxing the invariant — removing a
wire call still fails the gate:

    === ERROR: Generated operations NOT wrapped by service layer (1) ===
    ReplaceDocument
Copilot AI review requested due to automatic review settings August 3, 2026 10:11

@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: 2e8c29fbf1

ℹ️ 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 swift/Sources/Basecamp/DocumentsServiceExtensions.swift
Comment thread typescript/src/client.ts

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.

🟡 Human review recommended

It is a breaking wire-operation rename spanning the spec, six SDKs, generated artifacts, and conformance, and it deliberately carries byte-identical shared merge-safe helper files from the still-unmerged PR #597, so a human should verify the cross-PR coupling and breaking-change coordination before approval.

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

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

…e guards

The architecture table is what tells a future reader which hand-written service
files are loaded at runtime; the note under it says anything absent is only a
reference implementation. Six new composite files and three shared guard files
are loaded, so they belong in the table.

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.

🟡 Human review recommended

It is a breaking six-SDK wire-operation rename touching generated artifacts and depends on byte-identical shared guard files copied from the still-unmerged #597, whose landing order is an open coordination decision requiring human judgment.

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

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

@jeremy
jeremy merged commit dee221c into main Aug 3, 2026
47 checks passed
@jeremy
jeremy deleted the feat/documents-triad branch August 3, 2026 10:41
jeremy added a commit that referenced this pull request Aug 3, 2026
…605)

#601 added the documents merge-safe composite to SPEC.md, citing BC3 #12501
(2c0dafba13) as the commit that shipped Recording::DraftSubscribers and
pinning the surface to a provenance at or after it. #590 added
doc-constants-check, which rejects unmarked prose naming the current pin.
Each merged green on its own branch; composed on main, the gate reads #601's
two citations as unmarked pin restatements and every make check fails.

Both citations are as-of facts bound to BC3 #12501 — they stay true when the
pin advances — which is exactly what .unmarkedPinCitations grants. Record
SPEC.md there with count 2.
jeremy added a commit that referenced this pull request Aug 3, 2026
Pre-existing breakage on main, absorbed here because every branch inherits it
and nothing else is open against it: `main` at c441c23 fails Spec Gates on
this alone (job 91663534361), with the assertion-types finding this branch
introduced as the only difference.

#601's Documents section and #590's doc-constants gate landed within minutes of
each other, so the prose was never run past the gate. SPEC.md §Documents ended
with "this surface is pinned to a bc3 provenance at or after `2c0dafba13`" —
AGENTS.md's failure mode exactly, an as-of fact written in the grammar of a
current-value claim, true the day it was written and silently false at the next
repin.

Fixed the way AGENTS.md prefers: name the pin by reference rather than by value
("at or after that commit"), so the sentence restates no constant and cannot
drift. The remaining literal — "#12501 (`2c0dafba13`)" — is a genuine as-of
fact bound to the PR that shipped it, so it is recorded in
spec/doc-constants.json .unmarkedPinCitations with a count of 1, matching how
spec/api-gaps/README.md and folders-api.md already cite the same revision.
jeremy added a commit that referenced this pull request Aug 3, 2026
#590 landed `make doc-constants-check` on main after this branch was cut, and
it gates SPEC.md §19's table against the `conformance/schema.json` assertion
enum: a new type cannot ship undocumented. This branch adds `errorRaised` to
that enum, so the rebase inherited the obligation and Spec Gates went red with
"defines 22 assertion types, the table documents 21".

The row says what the type is for and what declaring it costs — it switches
the stop-on-mismatch policy off for that case, which is why every fixture
declaring it needs a control sibling.

The other finding in that same red run — SPEC.md §Documents restating the
current pin — was #601's, not this branch's, and #605 has since fixed it on
main. An earlier revision of this branch carried its own fix for it; that is
dropped, so the only line this PR adds to SPEC.md is the one above.
jeremy added a commit that referenced this pull request Aug 3, 2026
* Refuse a malformed GET field instead of writing it back (#576)

The shipped Todos and Cards merge-safe composites in Python, Ruby and
TypeScript read each writable field off a GET and PUT the FULL
representation back. Every value read is therefore a value written -- on a
call that never mentioned the field -- and none of the three validated what
they read. Two failure modes, the same defect wearing different clothes:

  erasure    a falsey non-string coalesced away, wiping the field
  corruption a non-string forwarded verbatim, writing a number, boolean,
             array or object where a string belongs

Probed against the unfixed code, one call each, `update(content:)` and
`update(title:)`:

  Python Todos    description=False,0,[],{}  -> PUT description=""
                  description=42,True,["x"]  -> PUT description=42 / True / ["x"]
                  assignees[0].id="100"      -> PUT assignee_ids=["100"]
  Python Cards    due_on=False,0,[],{}       -> PUT with due_on OMITTED, which is
                                               exactly how BC3 erases the date
                  due_on=42,True,["x"]       -> PUT due_on=42 / true / ["x"]
  Ruby Todos      description=false          -> PUT description=""
                  description=0,[],{},42,... -> PUT description=0 / [] / {} / 42
  Ruby Cards      every shape                -> PUT due_on=<shape verbatim>
  TS Todos        all eight shapes           -> PUT description=<shape verbatim>
  TS Cards        due_on=false,0             -> PUT with due_on OMITTED (erased)

All three now treat an absent key or an explicit null as genuinely empty,
pass an actual string verbatim, and raise before the PUT naming the field.
The ID-list fields get the analogous check: an array, of objects, each with
an integer id. One level up, the response itself must be an object -- on
main a scalar or null body produced a raw TypeError/AttributeError instead
of the documented statusless api_error.

The rule underneath: a composite is safe exactly when a decoder REJECTS a
wrong-typed field at runtime, not when a type merely claims one. Go
(json.Unmarshal) and Swift (Codable) genuinely refuse. TypeScript's
schema.d.ts is erased at build time and the generated Python and Ruby
services return an untyped dict/Hash, so those three do it by hand, in a
shared per-language helper (_merge_safe.py, merge_safe.rb, merge-safe.ts)
rather than six copies.

Kill coverage lands in the SHARED conformance fixtures, not per language.
This defect survived five consecutive review passes because each pass fixed
one instance; a shared fixture catches every instance at once, in every
runner, permanently. Four cases across todos_write.json and cards_write.json
assert errorRaised + requestCount 1 -- the guard must fire BEFORE the PUT,
because a guard that fires after has already lost the field.

errorRaised is a new assertion type, the code-agnostic inverse of noError:
the six SDKs refuse the same body by two different mechanisms (hand-written
guard vs model decoder) that share no canonical error code. Declaring it
also tells the Kotlin and Swift runners that a decoder rejection is the
point of the case rather than an under-specified fixture body.

Writing that fixture immediately earned its keep: it found a FOURTH affected
language. Kotlin's client-wide `Json { isLenient = true }` coerces a JSON
scalar into a String field, so `"description": 42` decodes to "42" and the
composite writes it back -- proven on the wire with a temporary
requestBody assertion. #576 lists Kotlin as structurally safe; it is not,
for scalars. It cannot be fixed by this PR's pattern either, since the
coercion happens at decode and the composite only ever sees a String, so
the fixtures use array/object shapes (which kotlinx.serialization does
reject) and the scalar hole is filed separately.

Red proof, against unfixed composites: 85 Python, 81 Ruby, 78 TypeScript
unit failures, and 4/4/2 conformance kill-case failures (py/rb/ts). Go,
Kotlin and Swift pass the kill cases both before and after, which is the
point.

Deliberately out of scope: the caller-side mirror (a closure assigning 42
inside edit), the Kotlin lenient-decoder hole, and the generated validating
layer that would make all of these guards deletable (#578).

* Close the errorRaised coverage gaps: a TS-discriminating kill case and six runner unit tests

Three verifier findings on #597, all about the new assertion proving less
than it claimed.

The Cards kill cases did not discriminate in TypeScript. The generated
updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and
RegExp.test coerces its argument to a string first, so ["x"] ("x") and {}
("[object Object]") were already rejected before the PUT with or without the
guard -- TypeScript conformance failed 2 kill cases against the unfixed
composites, not 4, and TypeScript Cards had no regression protection at all.
A fifth case uses ["2024-02-01"], which String() renders as exactly
"2024-02-01": the format check waves it through and only the guard stops it.
It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it
structurally as they do any JSON array in a String field, so the shared
fixture stays shared.

The errorRaised handler had no unit test in any runner. Its failing branch is
unreachable from conformance/tests/ -- every case declaring it is one the SDK
does refuse -- so a handler that accepted everything would report green in all
six runners at once, which is how #563 shipped a vacuous delayBetweenRequests
check. The predicate is split out per runner and tested on both directions,
with the message pinned verbatim in all six. Go also asserts the wiring, since
a typo'd case label would fall through to the default and assert nothing.

evaluateAssertions(dispatchFailed:) loses its default. The one call site passes
it, but the default fails closed: a future call site that omitted it would
report "the call succeeded" on a call that did not, reddening every errorRaised
fixture far from the actual bug.

Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError
without setting dispatchFailed, so a trapped child process read as a successful
call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion
from the union of the two signals rather than from call-site discipline.

* Keep the errorRaised kill fixtures honest with a control-sibling gate

Codex P2 on Runner.swift: declaring errorRaised switches OFF the #555
stop-on-mismatch policy in the decoder-backed runners. Swift's DecodingError
branch and Kotlin's MissingFieldException/SerializationException branches
normally fail loudly when a mock body no longer decodes into the generated
model; when the fixture declares errorRaised they treat the refusal as the
behaviour under test and pass. So if a model later gains a required field, or
any unrelated field in one of these large bodies drifts, the decode fails for a
reason unrelated to the field under test -- and errorRaised, requestCount: 1,
requestMethod and requestPath all still hold. The case keeps passing and stops
proving anything.

The protection turns out to already exist, structurally: every kill body is a
passing case's body with exactly one field perturbed, and that sibling does NOT
declare errorRaised, so it keeps the full #555 policy and fails loudly on
drift. Cards kill cases pair with update-preserves-due-on (differing only in
due_on), Todos with update-merge (differing only in description).

But nothing enforced the coupling -- edit one body without the other and it
silently breaks. So enforce the claim rather than asserting it in a comment,
which is the #576 lesson applied to #576's own fixtures: for every case
declaring errorRaised, some case in the same file that does not declare it must
have a mock body with the identical key set, differing in exactly one field.

Runs in make conformance-fixtures-check, which CI already invokes. Proven
non-vacuous: perturbing a kill body in a second field fails the gate (exit 1)
and names the case, the missing control and the repair.

* Pin the control gate to the response the kill case actually decodes

Codex P2 on the gate added a commit ago: it matched a kill body against ANY
queued response of ANY control case. Every kill case queues two object-shaped
responses -- response 0 is the GET whose decoder rejection is under test, and
response 1 is a decoy, queued so a runner cannot pass by exhausting the queue
instead of refusing the field. The decoy is never consumed. So an unconsumed
decoy could satisfy the gate while the body that actually gets decoded drifted
away from its control, which is the same vacuity the gate exists to prevent,
one level up.

Reachable, not theoretical. Drift the consumed body by a second field and make
the decoy differ from its control by exactly one, and the two gates split
cleanly: the old one reports `ok` at exit 0, the new one fails at exit 1
naming the case.

Now restricted on both sides: the FIRST mock response only, and a control
exercising the SAME operation -- a body that decodes into a different model
says nothing about whether this one still decodes.

* Move $comment out of the assertion properties map, and metaschema-check first

Codex P2: the errorRaised annotation sat INSIDE
properties.assertions.items.properties, so it declared a property literally
named "$comment" whose schema was a string. Draft 2020-12 requires every value
under `properties` to be a schema object or boolean, so conformance/schema.json
was not itself a valid schema -- and tests.schema.json references it, meaning a
validator that meta-validates would reject the whole conformance schema before
looking at a single fixture. Moved alongside `properties`, where JSON Schema
puts annotations.

The reason this shipped is that nothing checked it: the fixture pass validates
fixtures AGAINST the schema and never validates the schema itself, so an
invalid schema sails through. conformance-fixtures-check now runs
--check-metaschema over schema.json and tests.schema.json FIRST, because
validating fixtures against a schema that is not a valid schema proves nothing.

Red proof, through the make target rather than the bare validator: putting the
annotation back inside properties fails at REAL_EXIT=2 with

  conformance/schema.json::$.properties.assertions.items.properties['$comment']:
    '...' is not of type 'object', 'boolean'

* Require a kill case to deliver its malformed value in a 2xx response

Codex P2, the residual hole in the control gate: it compared operation and
body but not the response OUTCOME. Change an errorRaised case's first mock
response from 200 to 500, or to networkError while keeping its body, and the
SDK fails on the HTTP or transport error instead. errorRaised is satisfied by
that failure, requestCount / requestMethod / requestPath all stay green, and
the malformed field is never decoded -- the case goes green having tested
nothing, and body equality cannot see it.

A kill case's premise is that the malformed value arrived in a SUCCESSFUL API
response. That is what makes it the SDK's problem rather than the server's,
and it is why #576 classifies the refusal as a statusless api_error rather
than a transport or HTTP failure. So require it: the first mock response must
carry a 2xx status and no networkError.

Both failure modes proved red, each naming what it would have cost:

  status 500  -> "...so the call fails on the HTTP error before the body is
                 decoded" (REAL_EXIT=1)
  networkError -> "...so the call fails in transport and the body is never
                 decoded" (REAL_EXIT=1)

* Require the control response to reach its decoder too

Codex P2, the symmetric half of the previous commit: not_a_success was applied
to the kill response but not to the control. A control earns its keep only by
being DECODED -- that is what makes it fail loudly (#555) on model drift, which
is the entire protection the kill case borrows from it. A sibling answering 500
or networkError with an object body never reaches its decoder, so it can sit
green on its own HTTP/transport assertions while the drift it was supposed to
catch goes unnoticed in both bodies. Same check now, on both sides.

Red proof needed a second attempt, which is worth recording. Breaking a single
control (update-preserves-due-on -> 500) did NOT fail the gate: cards_write.json
has four non-errorRaised UpdateCard cases, and the gate correctly fell back to
update-explicit-clear, whose body also matches on the same key set differing
only in due_on. That first proof was vacuous -- it demonstrated the fallback
working, not the check.

With all four UpdateCard controls answering 500 the two versions split cleanly:
the pre-fix gate reports `ok` at REAL_EXIT=0, the post-fix gate fails all three
Cards kill cases at REAL_EXIT=1, naming the missing SUCCESSFUL (2xx) control.

* Reject a kill case whose 2xx never reaches a decoder, and self-test the gate

The control-sibling gate accepted any 2xx on both sides, which let a 204
through. A 204 is short-circuited before any parse — TypeScript returns
`undefined`, Kotlin returns `Unit` without calling `parse`, Go rewrites the
body to JSON `null` — so a kill case answering 204 never decodes its malformed
field. The composite fails because the record came back absent, `errorRaised`,
`requestCount: 1`, `requestMethod` and `requestPath` all still hold, and the
control, still a 200, stays green. The gate printed `ok` and exited 0 for
exactly that input: the same false green this gate exists to prevent, one
layer down.

Statuses are now an allowlist, {200, 201}, rather than a 204 exclusion. Two
constraints meet there: Go's success arm is exactly {200, 201, 204}, so 202,
203, 205 and 206 are never decoded there at all; and 204/205 carry no body by
definition. Closed-by-default, because a gate whose whole job is to prove a
body is decoded cannot prove that for a status nobody has reasoned about.
`not_a_success` is renamed `not_decoded` — a 204 does not fail the call, it
bypasses the decode, and the old name said the wrong thing.

Every rejection this gate makes was, until now, correct by inspection alone,
which is the standard that let #576 through five review passes. So it gets a
self-test: `conformance/test_check_kill_case_controls.py` crafts one input per
claimed rejection and asserts the gate refuses it, driven through the real
entry point via a new optional FIXTURE_DIR argument, with the real fixture set
run as a positive control. Reverting only the two new status branches turns
exactly four cases red — 204, 205, an undecoded 2xx, and the control-side 204 —
and nothing else, so the suite is measured non-vacuous rather than assumed so.

It runs inside `make conformance-fixtures-check`, which CI already invokes.

* Document errorRaised in SPEC.md §19's gated assertion table

#590 landed `make doc-constants-check` on main after this branch was cut, and
it gates SPEC.md §19's table against the `conformance/schema.json` assertion
enum: a new type cannot ship undocumented. This branch adds `errorRaised` to
that enum, so the rebase inherited the obligation and Spec Gates went red with
"defines 22 assertion types, the table documents 21".

The row says what the type is for and what declaring it costs — it switches
the stop-on-mismatch policy off for that case, which is why every fixture
declaring it needs a control sibling.

The other finding in that same red run — SPEC.md §Documents restating the
current pin — was #601's, not this branch's, and #605 has since fixed it on
main. An earlier revision of this branch carried its own fix for it; that is
dropped, so the only line this PR adds to SPEC.md is the one above.
jeremy added a commit that referenced this pull request Aug 13, 2026
SPEC §19's Test Categories table and Appendix D each claim to account for
every fixture under conformance/tests/, and both had drifted. The last
three fixture-adding commits missed the convention in three different
ways: dee221c (#601) added documents_write.json and updated neither
table; b238e5e (#683) added uploads_write.json with four Appendix D
rows and no §19 row; #726 added search.json's §19 row and missed
Appendix D. Two half-applications in opposite directions is not
carelessness — CONTRIBUTING.md tells contributors to add conformance
tests and mentions neither table.

Each cell is derived from the fixture's own description citations, which
is the convention the existing rows follow. documents_write.json cites
"SPEC 18 body compaction" and "SPEC 18 rule 6", and §5's Documents
subsection already back-references it; uploads_write.json cites "SPEC
§18", "SPEC.md §5" and "SPEC §6 step 11", the same four attributions its
Appendix D rows already spell out.

Also moves the search row into its sorted position, where #726 misfiled
it between retry and schedule-entries-write.

Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn
jeremy added a commit that referenced this pull request Aug 13, 2026
…them (#740)

* Account for every conformance fixture in both SPEC rosters

SPEC §19's Test Categories table and Appendix D each claim to account for
every fixture under conformance/tests/, and both had drifted. The last
three fixture-adding commits missed the convention in three different
ways: dee221c (#601) added documents_write.json and updated neither
table; b238e5e (#683) added uploads_write.json with four Appendix D
rows and no §19 row; #726 added search.json's §19 row and missed
Appendix D. Two half-applications in opposite directions is not
carelessness — CONTRIBUTING.md tells contributors to add conformance
tests and mentions neither table.

Each cell is derived from the fixture's own description citations, which
is the convention the existing rows follow. documents_write.json cites
"SPEC 18 body compaction" and "SPEC 18 rule 6", and §5's Documents
subsection already back-references it; uploads_write.json cites "SPEC
§18", "SPEC.md §5" and "SPEC §6 step 11", the same four attributions its
Appendix D rows already spell out.

Also moves the search row into its sorted position, where #726 misfiled
it between retry and schedule-entries-write.

Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn

* Gate both conformance-fixture rosters against git ls-files

`sync-doc-constants.rb` already does table-completeness checking:
@assertion-types wraps SPEC §19's assertion-type table in a block marker
and set-compares it against conformance/schema.json. The two fixture
rosters are the same shape one level out — a table that claims to
account for every fixture under conformance/tests/, with nothing
checking it — so they become two more block kinds rather than a new
script and a new CI step. `make doc-constants-check` already runs the
gate and its self-test, and is already in check-targets and spec-gates.

The source is `git ls-files conformance/tests/*.json`, not Dir.glob, for
tracked_markdown's reason: an untracked scratch fixture must not fail a
developer's build. Direct children only — git's pathspec `*` matches
across `/`, and a nested fixture is discovered by no runner, so
demanding a roster row for it would be documenting a claim that is not
true. That scope is also how SPEC §23's carve-out is honored:
conformance/oauth/, oauth-token/ and event-feed*/ are documented at
their own section and directory.

The two invariants differ because the artifacts differ. §19's table is a
bijection, so all of it is asserted: one row per fixture, both
directions, and category slug == basename with `_` as `-` (verified
across all 22 rows). Appendix D's rows are curated summaries that
deliberately bundle several cases — uploads_write.json legitimately has
four — so it gets coverage only, and a self-test case pins that
difference by asserting several rows for one fixture still passes.

Both tables also reject a row whose attribution cell is blank, and a
`§N` reference that resolves to no `## §N.` heading — the latter catching
a reference that resolved when written and stopped resolving when a
section was renumbered, which a reviewer of the same PR cannot see. A row
with no section reference at all is still accepted, because rejecting it
needs a carve-out for live-my-surface.json's external-governance
attribution and the carve-out list is the part that grows.

Neither table is writable: --write only ever touched line spans, and a
row here carries an owning-section attribution or a case summary only
the fixture's author can make.

Both checks reject SPEC.md as it stood before the preceding commit:

  SPEC.md:2110-2131: conformance/tests holds 22 tracked fixture(s), the
    table categorises 20; missing: `documents_write.json`,
    `uploads_write.json`.
  SPEC.md:3379-3458: no row maps these tracked fixtures to a primary
    section: `documents_write.json`, `search.json`.

Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn

* Keep block bodies under the pin scan, and reject colliding category slugs

Two review findings on the roster gate, both real interactions rather than
Markdown-spelling edge cases.

Block span bodies were dropped from the prose pool along with line spans, but
the two are not alike. The writer rewrites line spans only, and the block
checkers read nothing but the `|` rows, so an ordinary sentence parked inside
a roster or assertion-types block survives both untouched. Excluding the whole
body let "verified against <current pin>" sit there with no marker and no
grant — invisible to check_unmarked_pin and silently stale at the next repin,
which is the exact claim class this gate exists to catch, hidden by the gate's
own span bookkeeping. Only line spans leave the pool now.

The §19 categories table tallied FILES, which catches one fixture on two rows
but not two fixtures deriving one category. `_` and `-` collapse to the same
slug, so `foo_bar.json` and `foo-bar.json` each satisfy the per-row slug rule
while the table stops being the bijection its heading asserts. Now tallied on
the DERIVED slug — a row whose category cell is simply wrong is already
reported and still reaches that tally, so grouping by the declared cell would
both miss real collisions and invent false ones. Appendix D is unaffected: it
has no category column and deliberately allows many rows per fixture.

Both self-test cases were shown to fail against the un-fixed gate first —
reverting each fix in turn leaves exactly its own case reporting "expected
FAILURE, gate exited 0".

* Stop SPEC promising a gate this PR removed, and correct a comment inviting deletion

Codex raised the first as a P1 and Copilot as a suppressed comment; both were
right, and it is the defect class this PR family exists to prevent — prose
claiming coverage CI cannot provide.

SPEC §19 said `make check-fixture-execution` (#602) "is what detects it now".
That gate was the source-text parser split out of this PR to
conformance-skips-parser-archive; the prose describing it stayed behind. There
is no such script and no such target — `make -n check-fixture-execution` exits
"No rule to make target" — so the paragraph promised all-six detection that
does not exist, while #602 is still open.

Replaced with what is actually true: each runner's case census (#742) catches a
case executed by no runner for a MECHANICAL reason, and explicitly does not
catch the deliberate all-six exclusion this section describes, because each
census counts its own skip and stays green. The roster below it is restated
rather than derived, and nothing checks it (#736) — which is why #736 waits for
#602's cross-runner manifest instead of being fixed on its own.

Separately, roster_vacuity's comment claimed the guard "buys no coverage". That
is true only when ONE side is empty. When BOTH are, `missing` and `extra` are
both empty, the comparison is trivially satisfied, and this guard is the only
thing refusing the vacuous pass — a committed self-test case covers exactly
that. The comment as written invited deleting a live guard on the strength of
reasoning that applies to a different case.

* Require exactly three cells per roster row, and correct a self-falsifying claim

Copilot raised the cell count three times across two rounds; taking it, because
it asks for something different from the Markdown-spelling findings declined
alongside it.

Those asked the splitter to UNDERSTAND more Markdown — separator widths,
backslash parity. This asks it to REFUSE what it does not understand, which is
the direction this file already argues for: "a row the parser cannot see is a
row it silently vouches for." And it closes the pipe class as a class rather
than one spelling at a time — however a stray pipe was written, the cell count
is wrong and the row fails loudly instead of being mis-parsed quietly.

The consequence was real, not cosmetic. A raw pipe in an attribution shifts the
real section into a fourth cell and leaves the fragment before it in cells[2],
where non-`§` attributions are legitimately allowed — so the gate validated the
wrong cell and a `§99` in the actual section position was never checked, on a
gate whose whole claim is that it validates every section reference. Both
tables had it; Appendix D's free-form summaries are the likeliest place for
someone to write `supports A | B`.

Self-test cases added for both tables and shown to fail against `< 3` first.

CONTRIBUTING.md separately claimed the checklist item "is the only place this
convention was written down" — false the moment this PR also stated it in SPEC
§19 and the gate. Rephrased as the historical absence it describes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API conformance Conformance test suite go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK spec Changes to the Smithy spec or OpenAPI swift typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Documents triad: ReplaceDocument + merge-safe composites (gates on bc3 draft-subscriber fix)

3 participants