Skip to content

fix: stop the generated bindings from encoding states the contract forbids - #6

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
issue-1-feat-go-runnable-agent-after-python-contract-qualification
Sep 14, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
issue-1-feat-go-runnable-agent-after-python-contract-qualification

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Follow-up to #3. A review of that merge found the generated types carried the contract's optional and nullable declarations but left three states encodable that the declaration does not have — each of them silently. This fixes those and the smaller findings alongside them.

Verified by compiling the generated package and exercising it, not by inspection.

The two silent ones

Optional[T] dropped a value the author set. Present and Value were exported, so Optional[string]{Value: &s} — the natural literal — set a value without marking it present, IsZero reported absent, and omitzero deleted the key with no error. Fields are now unexported and the states reachable only through Absent, Null and Set. MarshalJSON on an absent value is an error rather than a null: absence is omitted with its key, so reaching the marshaler absent means it is being encoded where a key cannot be omitted, and null there would stand in for absent without saying so.

A required array encoded as null. An array was a Go slice and a nil slice encodes as null. The scaffold returns a zero Output, so the first payload a generated runnable produced wrote null for a required array; decoding {} and re-encoding it turned an absent array into an explicit null; and a null arriving for a non-nullable array decoded without error. Arrays are now List, which encodes an unset array as the empty array it is and refuses to decode a null one. A nullable array is *List, where nil is the null state. An optional array stays a bare List — a nil List is already the absent state omitzero omits, and the pointer it would otherwise get decodes the forbidden null as if it were absent.

omitzero is gated by the toolchain, not by the module's go directive (verified: a go 1.21 module still gets it under a 1.27 toolchain). An older toolchain ignored it in silence and turned every absent optional key into a null. Generated files now carry //go:build go1.24, which fails the build instead.

Alongside, at the same seams

  • exported() could return a non-identifier: core allows the field name _1, which generated the Go field 1 and surfaced as a gofmt parse error pointing into a file the author never sees. Names are checked against an exported-identifier pattern and the error names the contract field.
  • A type-name collision reported only the generated name, which is not a name anyone wrote. The generator carries each type's contract path and reports both (input.a_b and input.a.b).
  • pkg and the runnable name were interpolated into source unvalidated. A runnable name is a path component, so word-count reached gofmt as a parse error and a newline would have injected top-level Go into a file marked DO NOT EDIT.
  • Handler's doc promised the scaffold is "never rewritten over their implementation" while the package had no writer to keep that promise. WriteHandler creates the file with O_EXCL and reports whether it wrote one.
  • Bindings takes the contract and not the Runnable deliberately: Runnable.Validate also demands a pinned agent, entrypoint and execution block, none of which change a byte of output. Recorded at the signature rather than left open.
  • The compile test hardcoded the go directive next to go.mod's; it reads go.mod, so it cannot go on compiling at a version the repository has left.
  • Generated code is read as a diff by the author whose workspace it lands in, so a golden file pins the whole output: order, header, blank lines and the single emission of each carrier.
  • The contract-name to type-name rule is duplicated in runnable-python and belongs in core; a test pins it against that implementation's names until it moves.
  • CI ran with the repository's default token scope and skipped pull requests not targeting main — with auto-merge-on-green, a stacked PR with no checks reads as one that passed them. Added contents: read, dropped the branches filter.
  • docs/milestone_1_bindings_review.md recorded a review verdict ("found no blocking defect") as repository documentation. It cannot be kept true and this change disproves it. Removed, with the tracker narrative it duplicated in the README.

Not fixed here

#4 — an optional, non-nullable scalar is a *T, and encoding/json nils a pointer on null without consulting any unmarshaler, so a forbidden null decodes as absent. Same defect class as the array case, different type family; fixing it changes the author-facing spelling of every optional scalar, so it belongs with the harness's raw-payload validation decision.

Separately, runnable-python has a live silent bug this surfaced: nestedName() has no collision check, so a_b + a.b emits two classes named InputAB and the second shadows the first. Go errors on that contract; Python mistypes it. Posted to the shared blackboard with a repro.

Bearing on #1

#1's checked box reads "generated code compiles and its presence/value cases are exercised". That was checked on the code this PR fixes. The box is accurate once this lands.

🤖 Generated with Claude Code

…rbids

The generated types carried the contract's optional and nullable
declarations but left three states encodable that the declaration does
not have, each of them silently.

Optional[T] exported Present and Value, so Optional[string]{Value: &s} —
the natural literal — set a value without marking it present, IsZero
reported absent, and omitzero dropped the key from the payload with no
error. The fields are now unexported and the states reachable only
through Absent, Null and Set, so no literal can spell a state the
contract does not have. MarshalJSON on an absent value is an error
rather than a null: absence is omitted with its key, and reaching the
marshaler absent means it is being encoded where a key cannot be
omitted, where null would stand in for absent without saying so.

An array was a Go slice, and a nil slice encodes as null. The scaffold
returns a zero Output, so the first payload a generated runnable
produced wrote null for a required array; decoding {} and re-encoding it
turned an absent array into an explicit null; and a null arriving for a
non-nullable array decoded without error. Arrays are now a List, which
encodes an unset array as the empty array it is and refuses to decode a
null one. A nullable array is a *List, where nil is the null state. An
optional array is a Present[List[T]]: Present, which #4 added on main
while this branch was open, refuses the null that a pointer around a
List would have swallowed before the List ever saw it.

omitzero is not gated by the module's go directive, only by the
toolchain, so an older one ignored it in silence and turned every absent
optional key into a null. Generated files now carry //go:build go1.24,
which fails the build instead.

Alongside, at the same seams:

- exported() could return a non-identifier: core allows the field name
  _1, which generated the Go field 1 and surfaced as a gofmt parse error
  pointing into a file the author never sees. Names are checked against
  an exported-identifier pattern and the error names the contract field.
- A type-name collision reported only the generated name, which is not a
  name anyone wrote. The generator now carries each type's contract path
  and reports both (input.a_b and input.a.b).
- pkg and the runnable name were interpolated into source unvalidated. A
  runnable name is a path component, so word-count reached gofmt as a
  parse error and a newline would have injected top-level Go into a file
  marked DO NOT EDIT. Both are validated at the entry points.
- Handler's doc promised the scaffold is "never rewritten over their
  implementation" while the package had no writer to keep that promise.
  WriteHandler creates the file with O_EXCL and reports whether it wrote
  one, so a regeneration cannot overwrite an author's handler.
- Bindings takes the contract, not the Runnable, deliberately:
  Runnable.Validate also demands a pinned agent, entrypoint and
  execution block, none of which change a byte of output. Recorded at
  the signature.
- The compile test hardcoded the go directive next to go.mod's; it now
  reads go.mod, so it cannot go on compiling at a version the repository
  has left.
- Generated code is read as a diff by the author whose workspace it
  lands in, so a golden file pins the whole output: order, header, blank
  lines and the single emission of each carrier.
- The naming rule is duplicated in runnable-python and belongs in core;
  it is pinned by a test against that implementation's names until it
  moves.
- CI ran with the repository's default token scope and skipped pull
  requests that did not target main — with auto-merge-on-green, a stacked
  pull request with no checks reads as one that passed them. Added
  contents: read and dropped the branches filter.
- docs/milestone_1_bindings_review.md recorded a review verdict ("found
  no blocking defect") as repository documentation; it cannot be kept
  true and this change disproves it. Removed, with the tracker narrative
  it duplicated in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AntoineToussaint
AntoineToussaint force-pushed the issue-1-feat-go-runnable-agent-after-python-contract-qualification branch from ea21ad1 to 4fb9283 Compare September 14, 2026 13:09
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 9806a29 into main Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant