Skip to content

refactor(api): Prepare the public surface for v1 - #50

Merged
korya merged 9 commits into
masterfrom
korya-api-v1-surface
Sep 11, 2026
Merged

korya merged 9 commits into
masterfrom
korya-api-v1-surface

Conversation

@korya

@korya korya commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Problem

Tagging v1 freezes the public API under Go's compatibility promise, and four things
should not be frozen in their current shape.

The worst is invisible. LatencyStats, StageMedians, Confidence and
StabilityParams were aliases of internal/engine types, so pkg.go.dev showed callers
type StabilityParams = engine.StabilityParams and none of the thirteen fields behind
it. Worse, internal/engine was not actually internal: renaming a field there broke
every importer's build. I verified this by renaming one field and building a consumer
program against it, which failed with unknown field ProbeCapacityPercent in struct literal of type netquality.StabilityParams while apidiff on the public package
reported no changes at all. Pointing apidiff at the internal package returns
Ignoring internal package even with -allow-internal, so no CI configuration can
catch this class of break. The fix had to be structural.

The rest is ordinary surplus: two entry points differing by one argument, and three
symbols exported by accident rather than intent.

Solution

The four types are declared in this package now, carrying the field documentation the
aliases hid, and the engine's values are converted at the five call sites that already
crossed the seam. LatencySample is gone from the public API rather than mirrored,
because nothing in Result ever reached it. Options.Events carries the progress sink,
leaving Run as the only entry point. ParseServerConfig and DefaultStabilityParams
are unexported.

JSON tags are copied verbatim, so the wire contract is untouched and schema_version
stays 1 (INV-7). TestResultSchemaGolden passes without the golden file changing, which
is the proof.

Un-aliasing introduces one new way to fail: two declarations drifting apart. A renamed
engine field now fails to compile in convert.go, and one added but not carried across
fails the conversion tests. I verified both by making each change and watching the right
guard fire.

Finally, scripts/api-compat.sh and a CI job report incompatible API changes against the
newest tag. It is advisory while breaking changes are still allowed, and turns blocking
when v1.0.0 is tagged. The tool is pinned and run through go run, like govulncheck, so
it never enters go.mod (INV-5).

Other Changes

LimitListener stays public: its Close releasing an Accept waiting at the cap is a
real improvement over the x/net equivalent, and a self-hoster building their own binary
wants it.

mutex_bench_test.go arrived from #49 mid-branch and names the engine's sample type now.
All benchmarks from that PR still run.

The last commit is unrelated to the API and can be dropped on its own if you would rather
keep this PR pure: a style pass over the README that replaces twenty-one em and en dashes
with sentences, spells out four table cells that used a bare dash to mean "the draft says
nothing here", and removes a non-breaking hyphen hiding inside percent-encode. No
figures, deviations or documented limitations were rewritten.

No screenshots: nothing rendered changed.

Related: INV-5, INV-7, RES-8, #49

🤖 Generated with Claude Code

https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa

korya and others added 8 commits September 10, 2026 23:54
LatencyStats, StageMedians, Confidence and StabilityParams were aliases of
internal/engine types. pkg.go.dev cannot document a type it cannot reach, so
callers saw "type StabilityParams = engine.StabilityParams" and no fields, and
a rename inside internal/engine silently broke every importer while apidiff on
the public package reported nothing at all.

Declare the four in this package, with the field documentation the aliases
hid, and convert at the five call sites that already crossed the seam. Two
exports go with them: LatencySample, which nothing in Result reaches and which
run.go and probe.go now name through the engine, and DefaultStabilityParams,
whose values a zero StabilityParams already selects.

JSON tags are copied verbatim, so the wire contract is unchanged and
schema_version stays 1 (INV-7); TestResultSchemaGolden passes untouched.

The seam needs guarding both ways: a renamed engine field fails to compile in
convert.go, and an added one fails TestPublicTypesMirrorEngineTypes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
Run and RunWithEvents differed by one argument, and Run was a one-line
forward. A caller who wanted progress had to switch entry points rather than
set a field, and the pair had to be kept in step for ever once v1 is tagged.

Options.Events carries the sink now and Run is the only entry point. The
concurrency contract (RES-8) moves onto the field, where a caller reads it
while writing the thing it constrains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
ParseServerConfig had no caller outside this package and its own tests. Run
performs discovery itself, and a caller who wants to parse a document without
running a test is not a use case anyone has asked for; exporting it at v1
would commit us to the signature for ever.

ErrInvalidConfig stays exported: Run wraps it, so errors.Is remains the way to
tell an invalid document from a transport failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
Nothing told us when an exported symbol changed shape. v0.3.0 quietly made
Options non-comparable, and the only reason we know is that apidiff was run by
hand, once, a year later.

scripts/api-compat.sh compares every public package against the newest version
tag and exits non-zero on an incompatible change. apidiff always exits 0, so
the empty report is the pass condition rather than its status. The tool is
pinned and run through `go run`, like govulncheck, so it stays out of go.mod
(INV-5).

The job is advisory while breaking changes are still allowed. It turns
blocking when v1.0.0 is tagged, by dropping continue-on-error.

It cannot see everything: a type aliased out of an internal package is beyond
its reach, which is the other half of why those aliases are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
architecture.md described the public latency and confidence types as aliases
of engine types, which was the deliberate choice this branch reverses, and
RES-7 named RunWithEvents. Both now describe what the code does.

The release steps gain the API-compatibility script and say what tagging
v1.0.0 costs: the advisory job turns blocking, and an incompatible change
needs a /v2 module path from then on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
The first version of this guard asserted that the public types and their
engine counterparts were field-for-field identical, names included. That
re-couples the two declarations the branch had just separated: an engine-only
rename, correctly absorbed by the converter, would still fail the test with
nothing actually wrong.

What matters is that no value is lost crossing the seam. Filling every engine
field with a distinct value and round-tripping it catches a dropped field
without caring what anything is called, and matching result fields by name
rather than position lets either declaration be reordered freely.

A rename still fails to compile in convert.go, which is where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
BenchmarkPhaseStateSamples landed on master while this branch was removing
LatencySample from the public API. The benchmark measures phaseState, which is
internal, so it follows run.go and probe.go in naming the engine's type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
continue-on-error keeps a job from failing the workflow, but the check still
renders red. Every pull request between here and v1.0.0 would carry a red X
for a break that is allowed, which is how teams learn to stop reading red.

The step reports the break as a warning annotation with the full diff in the
job summary, and exits 0. The script still exits non-zero, so running it by
hand and gating on it after v1 both behave correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
@korya
korya marked this pull request as ready for review September 11, 2026 04:14
Twenty-one em and en dashes, most of them clamping an aside that works
better as its own sentence. The signed-URL formula was buried between two of
them. Four table cells used a bare en dash to mean "the draft says nothing
here", which they now say outright.

Also fixes a non-breaking hyphen hiding inside percent-encode, where it is
invisible and breaks search, and names the server as the actor in the
byte-credit paragraph instead of leaving the checks to happen by themselves.

No content rewritten: the numbers, the deviations table and the documented
limitations are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuMYXaDafWUk2fAjyEvFDa
@korya
korya merged commit 7512613 into master Sep 11, 2026
13 checks passed
@korya
korya deleted the korya-api-v1-surface branch September 11, 2026 04:22
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