Skip to content

Register the 20 missing live operations in all four replay decoders (#553) - #595

Merged
jeremy merged 1 commit into
mainfrom
fix/553-replay-decoder-parity
Aug 3, 2026
Merged

jeremy merged 1 commit into
mainfrom
fix/553-replay-decoder-parity

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

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

The fix

Each registration goes through that SDK's real decode boundary:

Runner 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.

Red proof

This PR's guard against pristine origin/main (5efc52f09). 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.

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 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 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), 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.

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 (column-1 anchor, as pushed at 784c15b52) — 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 PR'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. 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 — 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 — intended, and why the visibility coupling was worth removing rather than living with: private/internal is 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 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. 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's index() (at column 1 for four tables, anywhere on the line for Kotlin's), keys with a sed -E capture, no grep at all — and is 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

So the registrations are also unit-tested in-language — in the two files #594 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 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, measured on this commit)

./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

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 before this PR extended it — also pytest -q --collect-only on 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's live-my-surface row said 30 cases; the fixture has 31.

No spec files touched (spec/basecamp.smithy, openapi.json unchanged).

Copilot AI review requested due to automatic review settings August 3, 2026 06:23
@jeremy jeremy added the bug Something isn't working label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/test.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@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: 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".

Comment thread scripts/check-replay-decoder-parity Outdated
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 1e6f840 to 784c15b Compare August 3, 2026 06:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

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, pinned expected_live_ops=31, fail-closed on empty extraction) wired into make check, the spec-gates CI job, and as a conformance-live prerequisite.
  • Added in-language unit tests (Go shape-binding, Kotlin List<T>-vs-T shape tests, Python/Ruby shared-boundary assertions) and corrected doc counts in SPEC.md (30→31) and CONTRIBUTING.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.

Copilot AI review requested due to automatic review settings August 3, 2026 06:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

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

@jeremy
jeremy force-pushed the fix/572-runner-test-discovery branch from 7946b0c to 63e434f Compare August 3, 2026 09:00
Copilot AI review requested due to automatic review settings August 3, 2026 09:00
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 784c15b to 46992cb Compare August 3, 2026 09:00

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 force-pushed the fix/572-runner-test-discovery branch from 63e434f to 55fadde Compare August 3, 2026 09:04
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 46992cb to 46eb2aa Compare August 3, 2026 09:04
Copilot AI review requested due to automatic review settings August 3, 2026 09:04

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 force-pushed the fix/572-runner-test-discovery branch from 55fadde to 3e4a812 Compare August 3, 2026 09:13
Copilot AI review requested due to automatic review settings August 3, 2026 09:13
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 46eb2aa to 13e6bae 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.

@jeremy
jeremy force-pushed the fix/572-runner-test-discovery branch from 3e4a812 to 50d7402 Compare August 3, 2026 09:18
Copilot AI review requested due to automatic review settings August 3, 2026 09:49
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 256e8e6 to 1ff2025 Compare August 3, 2026 09:49

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 force-pushed the fix/572-runner-test-discovery branch from 10ca992 to 0f27bf4 Compare August 3, 2026 09:57
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 1ff2025 to 40099b1 Compare August 3, 2026 09:57
Copilot AI review requested due to automatic review settings August 3, 2026 09:57

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 force-pushed the fix/572-runner-test-discovery branch from 0f27bf4 to 08daa3c Compare August 3, 2026 10:06
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 40099b1 to 2bbc7a1 Compare August 3, 2026 10:06
Copilot AI review requested due to automatic review settings August 3, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

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

@jeremy
jeremy force-pushed the fix/572-runner-test-discovery branch from 08daa3c to 0396737 Compare August 3, 2026 10:24
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 2bbc7a1 to db00e40 Compare August 3, 2026 10:24
Copilot AI review requested due to automatic review settings August 3, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

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

@jeremy
jeremy force-pushed the fix/572-runner-test-discovery branch 2 times, most recently from b4ccca1 to 4f93478 Compare August 3, 2026 11:59
Base automatically changed from fix/572-runner-test-discovery to main August 3, 2026 12:04
…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
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from db00e40 to 9e5eb36 Compare August 3, 2026 12:06
Copilot AI review requested due to automatic review settings August 3, 2026 12:06

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 merged commit 20bcd9d into main Aug 3, 2026
44 of 45 checks passed
@jeremy
jeremy deleted the fix/553-replay-decoder-parity branch August 3, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working conformance Conformance test suite github-actions Pull requests that update GitHub Actions kotlin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replay decoder maps drifted: 20 live operations missing from all four runners + no static parity guard

2 participants