Skip to content

Publish the node configuration schema in the contract - #342

Merged
Babissimo merged 2 commits into
mainfrom
feat/publish-nodeconfig-schema
Sep 9, 2026
Merged

Babissimo merged 2 commits into
mainfrom
feat/publish-nodeconfig-schema

Conversation

@Babissimo

@Babissimo Babissimo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes 86cb6d7he.

Why

RegisterRequest.config is dict[str, Any], so the generated contract described
a node's configuration as a free-form object. Fifteen fields and every one of
their bounds were absent, where the hand-written 1.1.1 published them. The bounds
were still enforced, only undiscoverable: a client generated from the published
file sent an unvalidated blob and learned the rules from 400s.

This is also what #278 ran into. Making the six coordinate fields nullable moved
services/node_config.py and the node_configs table, neither of which the
generator can see, so its whole contract diff was the version line.

Published without being enforced

The field stays untyped, and that part is not negotiable: a Pydantic model there
would answer 422 ahead of the handler, putting a config-shaped refusal in front
of identity resolution and making the difference between it and a 403 an oracle
for which node identities exist.

WithJsonSchema replaces what is published and leaves validation alone, so the
shape reaches the document while the refusal stays where it was, behind identity
resolution and inside validate_config. The guard on that ordering, in
tests/test_node_register.py, is unchanged and green.

PUT /v1/nodes/config carries the same object through its openapi_extra. That
retires its "free-form here for the reason given on that endpoint", which pointed
at a rationale that only ever existed in a code comment.

What stops it drifting

config_json_schema builds twelve of the fifteen from _NUMERIC_BOUNDS, the
same table validate_config loops over, so those cannot disagree. The callsign
and the two beam fields are checked against their own literals, so they are
pinned separately, against validate_config at the boundary rather than against
the table: math.nextafter either side of every published bound, so a value the
document permits is one this end accepts. Verified by breaking
beam_width_deg's published maximum to 300 and watching that test fail.

Inline on both operations rather than one named component. Pydantic resolves
every $ref it emits against its own definitions, so a $ref out of
RegisterRequest.config raises KeyError for a schema that is not one of its
models.

Also here

  • The two tolerances publish no maximum. Their ceiling is math.inf, which is
    not a JSON Schema bound, and emitting it would state a limit the server does
    not have.
  • ONBOARDING and CLAUDE.md gain the new trap: a configuration bound now moves the
    contract with no route touched.

The version stays at 1.1.3

backend/routes/nodes.py had reserved a minor bump for publishing this schema.
It is not taken: the server accepts and refuses exactly what it did before, so
there is no change for a version to describe.

The cost is real and is recorded beside the constant rather than left implicit.
Two documents now carry 1.1.3, the later a superset of the earlier, and a client
cannot tell from the version alone which of the two it was generated from. That
is survivable here because the difference is additive and no node reads the
config schema at runtime, but it is the reason the note is there.

Verification

Backend suite green, pre-commit 5/5, contract regenerated and --check clean.
No node-side change is needed: a minor bump adds description, and every config
the fleet sends today still validates.

🤖 Generated with Claude Code

@claude

This comment has been minimized.

`RegisterRequest.config` is `dict[str, Any]`, so the generated contract
described a node's configuration as a free-form object: fifteen fields and
every one of their bounds were absent, where the hand-written 1.1.1 published
them. The bounds were still enforced, only undiscoverable, so a client
generated from the published file sent an unvalidated blob and learned the
rules from 400s.

The field stays untyped. A Pydantic model there would answer 422 ahead of the
handler, putting a config-shaped refusal in front of identity resolution and
making the difference between it and a 403 an oracle for which node identities
exist. WithJsonSchema replaces what is published and leaves validation alone,
so the shape reaches the document while the refusal stays behind identity
resolution, and PUT /v1/nodes/config carries the same object in its
openapi_extra. That also retires its cross-reference to a rationale which only
ever existed in a code comment.

Built from _NUMERIC_BOUNDS and _REQUIRED rather than written beside them, so
twelve of the fifteen cannot drift from the checks that enforce them. The
callsign and the two beam fields are checked against their own literals, so
those are pinned against validate_config at the boundary itself, with
nextafter either side of every published bound.

NODE_API_VERSION stays at 1.1.3. The server accepts and refuses exactly what it
did before this commit, so there is no change for a version to describe. The
cost is recorded beside the constant: two documents now carry 1.1.3, the later
a superset of the earlier, and a client cannot tell from the version alone
which of the two it was generated from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Babissimo
Babissimo force-pushed the feat/publish-nodeconfig-schema branch from a0be90a to d7901be Compare September 9, 2026 11:25
@claude

This comment has been minimized.

Ten findings from review of the previous commit. The two that were defects
rather than tidying:

config_json_schema returned the _UNTABLED_PROPERTIES dicts by reference, so
three of the fifteen properties were the module constant itself, shared by
every call, while the twelve built by _numeric_property were fresh. Both
callers hand their copy to a framework that mutates schema dicts in place, so
one operation's published bounds could have rewritten the other's. Deep-copied
on the way out, and the asymmetry that hid it is gone.

The version note claimed the second 1.1.3 document was a superset of the
first. It is not: `config` went from an object with no required keys and
additionalProperties true to fifteen required fields with unknown keys
forbidden, and its generated type from Config to NodeConfig. The document
narrows, to what this server has always enforced, and renames. That is the
honest statement of what carrying one version over two documents costs, and it
is what the note says now.

The rest:

- The schema is hoisted into one NodeConfig component by generate_openapi, at
  the layer that already shapes the document, rather than inlined twice.
  Pydantic still cannot emit the $ref, but the generator can, and a substitution
  that fails by doing nothing is pinned by a test asserting both bodies are the
  reference. 103 lines of duplication leave the contract.
- _numeric_property guards both ends against a non-finite bound, not just the
  high one. An unbounded low would have published a literal no JSON parser
  reads.
- _REQUIRED derives from the two tables instead of restating three field names.
- The boundary test parametrises off the published schema rather than a
  hand-written list, so a field added to either table is covered by the time it
  reaches the document.
- numeric_branch is defined once, beside the builder whose output it reads, and
  selects the number branch by type rather than by position.
- The untyped-config rationale is cited from routes/node_register.py rather than
  restated in three places.
- A docstring paragraph left ragged by the previous commit is rewrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 2m 34s —— View job


Reviewing PR #342

  • Gather context (PR body, changed files)
  • Understand the request (custom instruction: /code-review:code-review offworldlabs/retina-server/pull/342)
  • Run code review across correctness, reuse/simplification/efficiency, and altitude/convention angles
  • Post review findings

Review: Publish the node configuration schema in the contract (#342)

This PR was already reviewed once (see the earlier comment on this thread). Since then, commit 08bec69 ("Address review of the published configuration schema") landed. I diffed 1785d32..HEAD (the merge-base through both PR commits) fresh and read 08bec69 in full against the two issues the prior review raised.

Both prior findings are resolved

  1. Version-bump description mismatch — the prior review flagged that the PR description claimed a 1.1.3 → 1.2.0 bump while the code kept NODE_API_VERSION = "1.1.3". 08bec69 rewrites the version note at backend/routes/nodes.py:44-60 to say plainly that the document narrows and renames (config goes from an open object to fifteen required fields, additionalProperties: false, typed Config → NodeConfig) but the version doesn't move because there's no behavior change for it to describe. The current PR description now matches this exactly ("The version stays at 1.1.3"). Consistent.

  2. Duplicate schema instances / shared-reference mutation risk — the prior review's more substantive non-blocking note (two independently-built dict instances) turned out to hide a real bug: config_json_schema() returned _UNTABLED_PROPERTIES's three dicts by reference, so both publishing call sites shared the same mutable sub-dicts while frameworks under both mutate schema dicts in place. 08bec69 fixes this with deepcopy(_UNTABLED_PROPERTIES) (backend/services/node_config.py:123) and, going further, eliminates the duplication entirely: scripts/generate_openapi.py now hoists the inline schema into a single NodeConfig component (_hoisted, generate_openapi.py:100-118) that both operations $ref, guarded by test_both_operations_reach_one_configuration_component. Verified the closure logic (_closure/_referenced) still correctly resolves NodeConfig as reachable from the PUT /v1/nodes/config path and transitively from RegisterRequest.

Fresh pass over 08bec69

  • _numeric_property now guards both ends against non-finite bounds (math.isfinite(low)/math.isfinite(high)), not just the high one — correct, and matches _NUMERIC_BOUNDS, where every low is finite today but the guard is symmetric against a future unbounded low.
  • _REQUIRED is now derived (set(_NUMERIC_BOUNDS) | set(_UNTABLED_PROPERTIES)) instead of hand-listing three field names — matches validate_config's own field set.
  • numeric_branch (moved from a test-local _numeric_branch into services/node_config.py) selects the number branch by type == "number" rather than by anyOf position — more robust than the original index-0 assumption, and it's shared by both the boundary test and test_node_openapi.py.
  • The boundary test (test_node_config_validation.py) now parametrizes off the published schema (PUBLISHED_NUMERIC_FIELDS, derived from config_json_schema()) rather than a hand-written NUMERIC_FIELDS list, so a field added to either table is exercised without a second edit.
  • test_node_openapi.py's document fixture calls contract() live (not the committed YAML), so these tests validate what the generator currently produces, not stale committed text.
  • _hoisted matches by structural equality on the whole schema dict and rebuilds rather than mutates (app.openapi() caches its result) — correct, and the RuntimeError guard against a real NodeConfig Pydantic model colliding with the injected component name is a reasonable defensive check.
  • CLAUDE.md/ONBOARDING.md: the versions in the working tree differ from what's committed (harness artifact — the sandbox's system-prompt loader appears to have restored an older CLAUDE.md into the working tree). The committed CLAUDE.md/ONBOARDING.md at HEAD do carry the new trap ("a configuration bound in backend/services/node_config.py... moves the contract with no route touched"), matching the PR description. Not a real issue in the PR.

Non-blocking observation

_hoisted(declared, inline_config) in generate_openapi.py:157 walks and rebuilds the entire application's schema dict (every model across map/dashboard/admin routes, not just the node ones) before _closure prunes it down to what's reachable from the node paths. It's a build-time script, so this isn't a real cost, just a slightly wider recursion than the pruning that follows it would suggest is needed — not worth restructuring.

Not independently verified

No backend/.venv in this sandbox, so pytest/pre-commit run --all-files could not be run to confirm the PR's "backend suite green, pre-commit 5/5, contract regenerated and --check clean" claim — same limitation as the prior review pass, verified by static inspection only.

Conclusion

No new correctness issues found. Both findings from the previous review round are substantively addressed, and the fix for the shared-reference bug goes further than patching the symptom — it removes the duplication that made the bug possible.
· branch feat/publish-nodeconfig-schema

@Babissimo
Babissimo merged commit 210af73 into main Sep 9, 2026
30 of 31 checks passed
@Babissimo
Babissimo deleted the feat/publish-nodeconfig-schema branch September 9, 2026 12:10
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