Register the 20 missing live operations in all four replay decoders (#553) - #595
Conversation
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e6f840080
ℹ️ 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".
1e6f840 to
784c15b
Compare
There was a problem hiding this comment.
Pull request overview
This PR closes a false-green gap in the conformance harness. conformance/tests/live-my-surface.json declares 31 live operations, but the four wire-replay decoder maps (Go, Kotlin, Python, Ruby) had frozen at the original 11-operation surface — leaving 20 operations per runner (80 total) unregistered. Because each runner's runtime coverage gate only fires during a live canary (which skips when its secrets are unconfigured), this drift never surfaced in CI, yet it would abort the replay half of conformance-live/conformance-canary after a ~30-minute live capture. The PR backfills the missing registrations through each SDK's real decode boundary and adds a fast static parity guard so the drift can never recur silently.
I verified every decoder mapping against generated code: all 16 Kotlin "Everything" element types match everything.kt exactly (grouped ops → Bucket*Group, overdue → flat Todo/Card), the four other ops match their generated service return types, and all 20 Go *ResponseContent types exist as slices. The parity script's awk/grep anchors match each file's actual declaration and indentation, and the test helpers reference real fields/methods.
Changes:
- Registered 20 missing live operations (
GetProgressReport,GetBubbleUps,ListRecordings,Search, 16 Everything aggregates) in the Go, Kotlin, Python, and Ruby replay decoders through each SDK's real decode boundary. - Added
scripts/check-replay-decoder-parity(bidirectional set-equality across all 5 dispatch tables, pinnedexpected_live_ops=31, fail-closed on empty extraction) wired intomake check, thespec-gatesCI job, and as aconformance-liveprerequisite. - Added in-language unit tests (Go shape-binding, Kotlin
List<T>-vs-Tshape tests, Python/Ruby shared-boundary assertions) and corrected doc counts inSPEC.md(30→31) andCONTRIBUTING.md.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
conformance/runner/go/replay_runner.go |
Registers 20 decoders via generated *ResponseContent types; updates the sync comment |
conformance/runner/go/replay_runner_test.go |
Adds coverage (both directions) + one-wire-shape binding tests |
conformance/runner/python/replay_runner.py |
Registers 20 decoders via shared _decode parse+normalize |
conformance/runner/python/test_replay_runner.py |
Adds coverage + shared-boundary assertions |
conformance/runner/ruby/replay-runner.rb |
Registers 20 decoders via shared SDK_DECODE |
conformance/runner/ruby/replay_runner_test.rb |
Adds coverage + SDK_DECODE wiring assertions |
kotlin/conformance/.../ReplayRunner.kt |
Registers 20 decoders via typed models / ListSerializer; makes decoders internal |
kotlin/conformance/.../ReplayDecodersTest.kt |
New suite: coverage + list-vs-object shape binding |
kotlin/conformance/build.gradle.kts |
Sets test workingDir so the test resolves repo paths like the runners |
scripts/check-replay-decoder-parity |
New static parity guard across all 5 dispatch tables |
Makefile |
Wires the guard into make check and as a conformance-live prerequisite |
.github/workflows/test.yml |
Runs the guard in the spec-gates job |
SPEC.md |
Corrects live case count 30→31 |
CONTRIBUTING.md |
Documents why the runtime gate is insufficient and names the static check |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7946b0c to
63e434f
Compare
784c15b to
46992cb
Compare
63e434f to
55fadde
Compare
46992cb to
46eb2aa
Compare
55fadde to
3e4a812
Compare
46eb2aa to
13e6bae
Compare
3e4a812 to
50d7402
Compare
256e8e6 to
1ff2025
Compare
10ca992 to
0f27bf4
Compare
1ff2025 to
40099b1
Compare
0f27bf4 to
08daa3c
Compare
40099b1 to
2bbc7a1
Compare
08daa3c to
0396737
Compare
2bbc7a1 to
db00e40
Compare
b4ccca1 to
4f93478
Compare
…553) conformance/tests/live-my-surface.json declares 31 live operations. The four wire-replay decoder maps registered 11 — they froze at the original surface while later programs added GetProgressReport, GetBubbleUps, ListRecordings, Search and the 16 Everything aggregates to the fixture. 20 missing per runner, 80 registrations. (TypeScript's LIVE_OPERATIONS in live-dispatch.ts correctly carries all 31; this was a four-runner gap.) Consequence: every replay runner's coverage gate ("every fixture op has a decoder") fails the moment it runs, so `conformance-live` / `conformance-canary` cannot complete their replay half at all — after a ~30-minute live capture. Nothing surfaced because that gate only fires during a live canary and the scheduled canary workflow skips when its secrets are unconfigured. The "Keep this table in sync with LIVE_OPERATIONS — the coverage gate enforces parity" comment in replay_runner.go was true and unenforced. Each registration goes through that SDK's real decode boundary: Go generated.<Op>ResponseContent via json.Unmarshal Kotlin the typed model the generated service uses, and List<T> where the service does decodeFromString<List<T>> — Recording, Card, Todo, BucketTodosGroup, BucketCardsGroup, EverythingFile, TimelineEvent, Notification; ListSerializer(JsonElement) for Search, which the SDK returns as ListResult<JsonElement> Python json.loads + _normalize_person_ids (the SDK's only post-parse pass) Ruby JSON.parse + Basecamp::Http.normalize_person_ids Static guard: scripts/check-replay-decoder-parity compares all five dispatch tables against the fixture, both directions, with a pinned expected_live_ops=31 and check-idempotency-parity's fail-closed compare() — an extraction that matches nothing is a failure, not an "empty == empty" pass. Wired into `make check`, the spec-gates CI job, and as a PREREQUISITE of `conformance-live`, so a mismatch costs 0.3s instead of a wasted capture. The Kotlin arm was bound to a visibility keyword ----------------------------------------------------------------------- An earlier revision anchored the Kotlin table's start delimiter at column 1 as the literal `internal val decoders: Map<String, (String) -> Unit> = mapOf(`. This commit widens that map from `private` to `internal` so ReplayDecodersTest can see it — which means the delimiter matched on THIS branch and matched nothing on main, where it is still `private val decoders` (ReplayRunner.kt:76). So the red proof below did not report a parity gap for Kotlin at all; it reported compare()'s fail-closed diagnostic: FAIL: Kotlin decoders == live fixture internal error: empty operation set (expected=31, actual=0) — an extraction matched nothing Fail-closed did its job — nothing passed silently — but a visibility edit is not a parity failure and must not be reported as one, and the misreport hid the real 20-operation gap behind an internal error. The Kotlin arm now matches its start substring ANYWHERE on the line (block_anywhere), so `private`, `internal` and a bare `val` all work. The substring still carries the full type signature, so it cannot collide with prose in the KDoc above it. Red proof — the guard against pristine origin/main (5efc52f). Verbatim, with one marked elision: $ git archive origin/main | tar -x -C $W && cp scripts/check-replay-decoder-parity $W/scripts/ $ cd $W && ./scripts/check-replay-decoder-parity ==> Checking replay-decoder parity across five dispatch tables source of truth: 31 live operations in conformance/tests/live-my-surface.json ok: TypeScript LIVE_OPERATIONS == live fixture FAIL: Go decoders == live fixture missing decoders (live fixture operations with no registration): GetBubbleUps GetEverythingCheckins GetEverythingComments GetEverythingCompletedCards GetEverythingCompletedTodos GetEverythingFiles GetEverythingForwards GetEverythingMessages GetEverythingNoDueDateCards GetEverythingNoDueDateTodos GetEverythingNotNowCards GetEverythingOpenCards GetEverythingOpenTodos GetEverythingOverdueCards GetEverythingOverdueTodos GetEverythingUnassignedCards GetEverythingUnassignedTodos GetProgressReport ListRecordings Search FAIL: Python DECODERS == live fixture missing decoders (live fixture operations with no registration): [...] FAIL: Ruby DECODERS == live fixture missing decoders (live fixture operations with no registration): [...] FAIL: Kotlin decoders == live fixture missing decoders (live fixture operations with no registration): [...] ==> Replay-decoder parity FAILED (4 failure(s), 1 passed) REAL_EXIT=1 (Each [...] is the same 20 operation names printed above for Go, in the same order — 60 lines cut in total, nothing else. Verified byte-identical across all four blocks: 20 names each, sha1 48a8515844fb… taken over each block's name lines stripped of leading whitespace and joined with "\n".) That fourth block is the one the visibility fix bought: before it, the Kotlin line read `internal error: empty operation set`, and the 20 missing Kotlin registrations were invisible in the very proof that was meant to show them. Red proof, both other directions (scratch copy of this branch: one Python entry deleted, and the Kotlin map RENAMED — `decoders` to `decoderTable` — so its extraction matches nothing). Verbatim, no elision: ==> Checking replay-decoder parity across five dispatch tables source of truth: 31 live operations in conformance/tests/live-my-surface.json ok: TypeScript LIVE_OPERATIONS == live fixture ok: Go decoders == live fixture FAIL: Python DECODERS == live fixture missing decoders (live fixture operations with no registration): GetEverythingFiles ok: Ruby DECODERS == live fixture FAIL: Kotlin decoders == live fixture internal error: empty operation set (expected=31, actual=0) — an extraction matched nothing ==> Replay-decoder parity FAILED (2 failure(s), 3 passed) REAL_EXIT=1 A rename still breaks the extraction, and still fails closed — that is the intended behavior, and it is why the visibility coupling was worth removing rather than living with: `private`/`internal` is not a rename. And the visibility coupling itself, proved both ways against the real tree by respelling ReplayRunner.kt's `internal val decoders` back to main's `private val decoders` and changing nothing else. The pre-fix check (the column-1 anchor, as pushed at 784c15b) — verbatim, no elision: ==> Checking replay-decoder parity across five dispatch tables source of truth: 31 live operations in conformance/tests/live-my-surface.json ok: TypeScript LIVE_OPERATIONS == live fixture ok: Go decoders == live fixture ok: Python DECODERS == live fixture ok: Ruby DECODERS == live fixture FAIL: Kotlin decoders == live fixture internal error: empty operation set (expected=31, actual=0) — an extraction matched nothing ==> Replay-decoder parity FAILED (1 failure(s), 4 passed) REAL_EXIT=1 This commit's check, same tree, same `private` spelling — verbatim, no elision: ==> Checking replay-decoder parity across five dispatch tables source of truth: 31 live operations in conformance/tests/live-my-surface.json ok: TypeScript LIVE_OPERATIONS == live fixture ok: Go decoders == live fixture ok: Python DECODERS == live fixture ok: Ruby DECODERS == live fixture ok: Kotlin decoders == live fixture ==> Replay-decoder parity clean (5 checks passed, 31 operations) REAL_EXIT=0 A visibility edit is now invisible to the check, which is what it should be. A rename is not, which is also what it should be. That second one caught a real bug in this very check, on its first CI run. The Go extraction was `grep -oE '^\t"[[:alnum:]]+":'`, which matches under ugrep (a common macOS grep replacement, and what this machine runs) and under BSD grep, but matches NOTHING under GNU grep, where POSIX ERE has no \t escape. It went green locally and red on ubuntu with exactly that diagnostic. The extraction is now regex-light — table delimiters matched as literal text with awk's index() (at column 1 for four tables, anywhere on the line for Kotlin's, whose line begins with a visibility modifier), keys with a `sed -E` capture, no grep at all — and the fail-closed branch is what turned a silent false pass into a loud failure. Verified under a PATH restricted to /usr/bin:/bin as well as this machine's default tools. A parity guard proves registrations exist, not that they decode correctly, so the registrations are also unit-tested in-language — in the two files #572 just made discoverable, plus a new Kotlin suite: * Go replay_runner_test.go — coverage both directions against the fixture, and every decoder must accept exactly one of `[]` and `{}` (a decoder typed `any` would accept both and assert nothing). * Kotlin ReplayDecodersTest.kt — coverage both directions, plus shape binding: kotlinx rejects an array for a class serializer unconditionally and accepts `[]` for a list serializer, so "`[]` decodes" is an exact test of List<T>-vs-T. The four `My*` ops decode as bare JsonElement (the SDK's own return type) and are excluded by name, with a test asserting they really are shape-free and another asserting both exception sets name registered operations. * Python/Ruby — coverage both directions, and every entry must be the shared parse+normalize callable rather than a stub that would satisfy coverage while decoding nothing. Red proof for the Kotlin shape test — swapping one registration to its element serializer, which compiles and passes the parity guard: - "GetEverythingMessages" to { bt -> ...(ListSerializer(Recording.serializer()), bt) } + "GetEverythingMessages" to { bt -> ...(Recording.serializer(), bt) } $ cd kotlin && ./gradlew --quiet :conformance:test --rerun-tasks 17 tests completed, 1 failed [...] BUILD FAILED in 41s REAL_EXIT=1 ([...] elides Gradle's "See the report at: file://…" pointer and its "Try: Run with --scan" boilerplate. `--quiet` prints no per-test detail, so the assertion below is quoted from that run's JUnit XML, kotlin/conformance/build/test-results/test/TEST-com.basecamp.sdk.conformance.ReplayDecodersTest.xml, rather than from the console — testcase name and failure message verbatim, XML entities unescaped:) collection operations decode an empty array() org.opentest4j.AssertionFailedError: collection decoders bound to the element type instead of List<T> ==> expected: <[]> but was: <[GetEverythingMessages]> (REAL_EXIT=1 there is the bare `gradlew` binary. Through `make conformance-runner-tests-kotlin` the same failure is 2.) Verification (real exit codes): ./scripts/check-replay-decoder-parity 5 checks, 31 operations REAL_EXIT=0 PATH=/usr/bin:/bin ./scripts/check-replay-decoder-parity REAL_EXIT=0 ./scripts/check-runner-test-reachability 9 checks passed REAL_EXIT=0 ./scripts/check-runner-test-reachability --self-test 6 cases REAL_EXIT=0 make conformance-runner-tests-go ok (cached) REAL_EXIT=0 make conformance-runner-tests-python 20 passed, 31 subtests REAL_EXIT=0 make conformance-runner-tests-ruby 11 + 6 runs, 0 failures REAL_EXIT=0 make conformance-runner-tests-kotlin (--quiet, no output) REAL_EXIT=0 make conformance-runner-tests-swift 39 tests, 0 failures REAL_EXIT=0 cd kotlin && ./gradlew :conformance:test --rerun-tasks DelayGapsTest 10/0/0, ReplayDecodersTest 7/0/0 REAL_EXIT=0 cd conformance/runner/go && go build ./... && go vet ./... REAL_EXIT=0 make lint-actions No findings to report REAL_EXIT=0 Counts above are per the command on the same line. `make conformance-runner-tests-python` collects the whole conformance/runner/python directory, not one file: of that 20, test_replay_runner.py is 8 tests and all 31 subtests (`pytest -q test_replay_runner.py` → "8 passed, 31 subtests passed", REAL_EXIT=0). It was 5 tests before this commit extended it — that 5 is also `pytest -q --collect-only` on that one file, not a share of the 20. Local figures. Cite the CI job's own numbers when they differ. Docs: CONTRIBUTING.md's "each runner's coverage gate refuses to start until all five are in place" was the claim this issue disproves — it now says why that gate is not sufficient and names the static check. SPEC.md's live-my-surface row said 30 cases; the fixture has 31. Closes #553
db00e40 to
9e5eb36
Compare
Closes #553. Second of a three-PR stack. Stacked on #594 (
fix/572-runner-test-discovery) — merge that first; two of the unit tests added here live in the files #594 makes runnable. Review this PR's own commit only.The defect
conformance/tests/live-my-surface.jsondeclares 31 live operations. The four wire-replay decoder maps registered 11 — they froze at the original surface while later programs addedGetProgressReport,GetBubbleUps,ListRecordings,Searchand the 16 Everything aggregates to the fixture. 20 missing per runner, 80 registrations. TypeScript'sLIVE_OPERATIONSinlive-dispatch.tscorrectly carries all 31; this was a four-runner gap.Consequence: every replay runner's coverage gate ("every fixture op has a decoder") fails the moment it runs, so
conformance-live/conformance-canarycannot complete their replay half at all — after a ~30-minute live capture. Nothing surfaced because that gate only fires during a live canary and the scheduled canary workflow skips when its secrets are unconfigured. TheKeep this table in sync with LIVE_OPERATIONS — the coverage gate enforces paritycomment inreplay_runner.gowas true and unenforced.The fix
Each registration goes through that SDK's real decode boundary:
generated.<Op>ResponseContentviajson.UnmarshalList<T>where the service doesdecodeFromString<List<T>>—Recording,Card,Todo,BucketTodosGroup,BucketCardsGroup,EverythingFile,TimelineEvent,Notification;ListSerializer(JsonElement)forSearch, which the SDK returns asListResult<JsonElement>json.loads+_normalize_person_ids(the SDK's only post-parse pass)JSON.parse+Basecamp::Http.normalize_person_idsStatic guard:
scripts/check-replay-decoder-paritycompares all five dispatch tables against the fixture, both directions, with a pinnedexpected_live_ops=31andcheck-idempotency-parity's fail-closedcompare()— an extraction that matches nothing is a failure, not an "empty == empty" pass. Wired intomake check, thespec-gatesCI job, and as a prerequisite ofconformance-live, so a mismatch costs 0.3s instead of a wasted capture.Red proof
This PR's guard against pristine
origin/main(5efc52f09). Verbatim, with one marked elision:Each
[...]is the same 20 operation names printed above for Go, in the same order — 60 lines cut in total, nothing else. Verified byte-identical across all four blocks: 20 names each, sha148a8515844fb…taken over each block's name lines stripped of leading whitespace and joined with\n.The Kotlin arm was bound to a visibility keyword
Review-driven correction. An earlier revision anchored the Kotlin table's start delimiter at column 1 as the literal
internal val decoders: Map<String, (String) -> Unit> = mapOf(. This PR widens that map fromprivatetointernalsoReplayDecodersTestcan see it — which means the delimiter matched on this branch and matched nothing on main, where it is stillprivate val decoders(ReplayRunner.kt:76).So the fourth block above did not report a parity gap for Kotlin at all. It reported
compare()'s fail-closed diagnostic —internal error: empty operation set (expected=31, actual=0) — an extraction matched nothing. Fail-closed did its job (nothing passed silently), but a visibility edit is not a parity failure, and the misreport hid the real 20-operation gap behind an internal error. That fourth block, showing 20 missing Kotlin registrations, is what the visibility fix bought.The Kotlin arm now matches its start substring anywhere on the line (
block_anywhere), soprivate,internaland a barevalall work. The substring still carries the full type signature, so it cannot collide with prose in the KDoc above it.Proved both ways against the real tree, by respelling
ReplayRunner.kt'sinternal val decodersback to main'sprivate val decodersand changing nothing else. The pre-fix check (column-1 anchor, as pushed at784c15b52) — verbatim, no elision:This PR's check, same tree, same
privatespelling — verbatim, no elision:A visibility edit is now invisible to the check. A rename is not, which is also correct.
Both other directions
Scratch copy of this branch: one Python entry deleted, and the Kotlin map renamed —
decoderstodecoderTable— so its extraction matches nothing. Verbatim, no elision:A rename still breaks the extraction and still fails closed — intended, and why the visibility coupling was worth removing rather than living with:
private/internalis not a rename.The fail-closed branch caught a real bug in this check, on its first CI run
The Go extraction was
grep -oE '^\t"[[:alnum:]]+":'. That matches under ugrep (a common macOSgrepreplacement, and what this machine runs) and under BSD grep, but matches nothing under GNU grep, where POSIX ERE has no\tescape. Green locally, red on ubuntu, with exactly that internal-error diagnostic (thread).Had
compare()treated "no diff between two empty sets" as a pass, this check would have shipped permanently vacuous for Go. The extraction is now regex-light — table delimiters matched as literal text with awk'sindex()(at column 1 for four tables, anywhere on the line for Kotlin's), keys with ased -Ecapture, nogrepat all — and is verified under aPATHrestricted to/usr/bin:/binas well as this machine's default tools.A parity guard proves registrations exist, not that they decode
So the registrations are also unit-tested in-language — in the two files #594 just made discoverable, plus a new Kotlin suite:
replay_runner_test.go— coverage both directions against the fixture, and every decoder must accept exactly one of[]and{}(a decoder typedanywould accept both and assert nothing).ReplayDecodersTest.kt— coverage both directions, plus shape binding: kotlinx rejects an array for a class serializer unconditionally and accepts[]for a list serializer, so "[]decodes" is an exact test ofList<T>-vs-T. The fourMy*ops decode as bareJsonElement(the SDK's own return type) and are excluded by name, with a test asserting they really are shape-free and another asserting both exception sets name registered operations.Red proof for the Kotlin shape test — swapping one registration to its element serializer, which compiles and passes the parity guard:
[...]elides Gradle's "See the report at: file://…" pointer and its "Try: Run with --scan" boilerplate.--quietprints no per-test detail, so the assertion is quoted from that run's JUnit XML (kotlin/conformance/build/test-results/test/TEST-com.basecamp.sdk.conformance.ReplayDecodersTest.xml) rather than from the console — testcase name and failure message verbatim, XML entities unescaped:REAL_EXIT=1there is the baregradlewbinary. Throughmake conformance-runner-tests-kotlinthe same failure is 2.Verification (real exit codes, measured on this commit)
make conformance-runner-tests-pythoncollects the wholeconformance/runner/pythondirectory, not one file: of that 20,test_replay_runner.pyis 8 tests and all 31 subtests (pytest -q test_replay_runner.py→ "8 passed, 31 subtests passed",REAL_EXIT=0). It was 5 before this PR extended it — alsopytest -q --collect-onlyon that one file, not a share of the 20.Local figures — cite the CI job's own numbers where they differ.
Docs
CONTRIBUTING.md's "each runner's coverage gate refuses to start until all five are in place" is the claim this issue disproves — it now says why that gate is not sufficient and names the static check.SPEC.md'slive-my-surfacerow said 30 cases; the fixture has 31.No spec files touched (
spec/basecamp.smithy,openapi.jsonunchanged).