Skip to content

20260916 - Adopt node ingest v1.2.2, and register a node that does not know where it is - #18

Merged
Purple10101 merged 8 commits into
mainfrom
20260916-adopt-spec-v122
Sep 16, 2026
Merged

Purple10101 merged 8 commits into
mainfrom
20260916-adopt-spec-v122

Conversation

@Purple10101

Copy link
Copy Markdown
Collaborator

Eight commits, four separable strands. Happy to split into separate PRs if you would rather review them apart; they are grouped here because they were found and verified together.

The retina-gui half of the contact work is offworldlabs/retina-gui#90. They are independent: this side treats an absent contact document as a complete answer, so neither breaks the other whichever way round they merge.

1. The contract

a804be4 adopts v1.2.2, three revisions on from the 1.1.1 we were implementing. a64e655 then does what those revisions were for: an unsited node registers with seven explicit nulls instead of holding.

Under v1.1.1 the wire could not carry a null geometry, so registration was held and the fleet had no sight of a node nobody had configured. v1.2.0 made the six coordinates required-and-nullable and v1.2.2 did the same for tx_callsign, which is what makes the first change reach the case it was made for: a tower's name and its position are set at the same wizard step, so a node with no position has no name for one either.

is_located stays and gates nothing. It feeds the status document, which is worth keeping precisely because an unsited node now looks entirely healthy from outside: registered, streaming, heartbeating, contributing nothing.

The generator needed help, and that is the part most worth a look. v1.2.0 onwards is exported from the server's own FastAPI models, which spell nullable as anyOf: [X, {type: null}] where the hand-written 1.1.1 used type: [x, "null"]. Given anyOf, datamodel-codegen cannot attach a branch's constraints to a union member, so it manufactures a RootModel wrapper per field, and --collapse-root-models does not reach those. Adopting 1.2.0 raised the count from 3 wrappers to 19, which is RxLat(root=51.4) at construction and .root at every read. tools/normalise_spec.py rewrites that one idiom on a temporary copy; docs/node-ingest-v1.yml stays byte-identical to what was published.

Checked NodeConfig.beam_width_deg on adoption per the working agreement. It survived.

2. The contact endpoint

cead54e reads /data/retina-gui/telemetry-contact.json and sends it. c86e753 corrects the deploy fragment.

The discipline is all in when not to send. A node with nothing to report never calls the endpoint — an empty document is a valid payload that clears what the server holds, right for an owner who deleted their details and wrong for one who never gave any. Sent on local change only, compared by frozen-dataclass equality.

It rides the config loop and runs before the configuration, unguarded by it: a node whose config.yml is unreadable can still say who owns it, and that is exactly the node someone needs to ring.

A refusal goes to errors[] and never to the status document's detail. A rejected contact breaks nothing; detail is for what stops a node working.

No compose change was needed — /data/retina-gui is already mounted read-only for the consent records.

3. The live scripts had silently stopped testing anything

Possibly the most urgent strand. f282541, e3a4fa9 and aaa7865.

All four live-*.sh scripts set no WIZARD_FLAG_PATH and never mounted /data/retina-gui, so since the setup-wizard gate landed on 2026-08-26 every one of them held at setup_incomplete and tested nothing past the first gate. Three weeks of verification proving nothing while looking like it ran.

live-stalled.sh had a second version of the same problem and reported reached stalled: NO with nothing having gone wrong.

They also synthesised consent records on the grounds that retina-gui persists none, which stopped being true at v0.7.0.

4. A refused registration now says why

95f59f9. It went to errors[] and nowhere else, and nothing reads that list: retina-gui renders detail and ignores the rest.

owl-ded9 is what that costs. It has been refused 232 times over nine days with a 400 naming tx_lat — its receiver and illuminator are at the same point — and its owner saw a blank line. Three refusals now get three sentences, because they point at different people. The 403 is shown from the first refusal, with the reassurance in the wording rather than in a delay.

Verification

All gates pass including tools/check.sh --tracked.

Verified on owl-ded9 against a tunnelled mock, on restored release software (blah2:v0.4.3) after cleaning development builds off the node:

Script Result
live-probe Stages 1 and 2, every check passed against live config and real frames
live-service Real config refused exactly as production refuses it, detail naming tx_lat. Unsited registers and streams at 1.06 Hz
live-failures 409, 429, pause, resume and 401 all driven; detections stopped dead after the 401 while heartbeats continued
live-stress One registration across three processes, boot_id restarting seq each time with nothing dropped, unreadable config still beating with config_version: null
live-stalled First confirmation of stalled on real hardware: streaming → stalled with blah2=down, then recovery. It reported stalled rather than error, which is the distinction v1.1.1 added the value for

No required-nullable key was dropped from any payload across the whole sweep.

Known, and not addressed here

owl-ded9's receiver is still on the tower's coordinates, so every run above needed UNSITE=1. Correcting it needs the node's true position. After this lands it will at least explain itself on its own home page.

🤖 Generated with Claude Code

Purple10101 and others added 8 commits September 16, 2026 18:29
The contract moved three times since we adopted 1.1.1, and this takes all of
it. Behaviour is unchanged: what moves here is the type of things, not what
the node does with them.

What the revisions carry:

  1.2.0  the six NodeConfig coordinates became required-and-nullable, so a
         node whose owner cannot supply the geometry can still build a
         payload. PUT /v1/nodes/contact appeared. NodeState gained no values
         but lost its caveat: `stalled` is confirmed, and the one-line revert
         we kept ready is retired. The error taxonomy split invalid_request
         into invalid_config, invalid_contact and invalid_body, and every
         operation gained a 413, an x-retry, an x-terminal and a body cap.
  1.2.2  tx_callsign became nullable too, which is what makes the 1.2.0
         change reach the case it was made for: a tower's name and its
         position are set at the same wizard step, so a node with no position
         has no name for one either. The contact document became a named
         schema and gained a phone country.

Fourteen fields are now required-and-nullable rather than seven. The canary in
tests/wire/test_serialise.py failed on adoption, which is what it is for, and
the inventory it pins is updated by hand so the growth is a visible edit.

The generator needed help. 1.2.0 onwards is exported from the server's own
FastAPI models, which spell nullable as `anyOf: [X, {type: null}]` where the
hand-written 1.1.1 used `type: [x, "null"]`. The two mean the same thing and
datamodel-codegen treats them very differently: given anyOf it cannot attach a
branch's constraints to a union member, so it manufactures a RootModel wrapper
per field. --collapse-root-models does not reach those, because they are
synthesised from an inline branch rather than declared as a $ref. Adopting
1.2.0 raised the count from 3 wrappers to 19, which is RxLat(root=51.4) at
construction and .root at every read, spreading a generation artefact through
code that should only ever see floats.

So tools/normalise_spec.py rewrites that one idiom on a temporary copy, and
generate-models.sh reads the copy. docs/node-ingest-v1.yml stays byte-identical
to what the server author published, which is the point of keeping it
read-only. A nullable $ref has no type to hoist and is left alone; so is
anything that is not exactly a two-member union with null.

Two renames fall out of the export inlining its named scalar schemas: the
NodeState enum is now generated as State, and Adsb is a plain Enum rather than
a StrEnum now that it is nullable, so it can no longer be compared to a string.
The second is the trap tests/wire/test_heartbeat.py already documented for
Blah2; both are now the same shape and the asymmetry is gone.

Checked NodeConfig.beam_width_deg on adoption, per the working agreement. It
survived, nullable as agreed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
retina-node has shipped location.* null since 3fd4208, so a node between first
boot and its owner reaching the tower step has no geometry at all. We tolerated
that already: `_optional` reads the seven keys and `is_located` says whether
the node knows where it is. What we did with the answer was hold registration,
because the wire could not carry a null geometry and asserting a position
nobody chose is worse than silence.

The consequence was that the fleet had no sight of a node nobody had
configured. It could not be counted, its health was unknown, and the only
evidence it existed at all was a status document on the node itself.

Spec v1.2.0 made the six coordinates required-and-nullable for exactly this,
and says what the server does with them: such a node "registers and streams,
and its detections are counted, but it places nothing on the map until a
position arrives". v1.2.2 did the same for tx_callsign. So the gate goes, and
an unsited node now registers with seven explicit nulls.

`is_located` stays and gates nothing. It feeds the status document, and that is
worth keeping precisely because an unsited node now looks entirely healthy from
outside: registered, streaming, heartbeating, contributing nothing. That
sentence in `detail` is the only thing telling an operator why nothing of
theirs appears on the map, so the wording changed with the behaviour.

Nothing is substituted. `m_to_ft` passes None through rather than converting an
absent altitude, because 0.0 ft is sea level and a real place to claim to be.
Between v1.2.0 and v1.2.2 this branch briefly sent "unknown" for the callsign,
since that field was left non-null while the coordinates went nullable; the
server author made it nullable instead and the placeholder is gone. Stage 2 now
substitutes nothing at all.

The mock gains the three rules the contract states and a JSON schema cannot:
the six coordinates accept null, a latitude and its longitude are given
together or both null, and the degenerate-baseline check only runs once both
ends have a position. That last one would otherwise subtract a null and turn
the ordinary state of a new node into a 500. `tx_callsign` accepts null but
still refuses the empty string, which the schema is explicit about: a node that
cannot name its illuminator has exactly one way to say so.

Also removes a duplicate `_optional` definition left by 9ed838d. The two bodies
were identical so nothing was wrong, and neither ruff nor vulture flags it:
F811 does not fire because the name is used from a deferred function body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
live-service.sh has been a no-op past its first gate since 1f1b990 landed on
2026-08-26. That commit made registration wait on the setup wizard flag at
/data/retina-gui/setup-wizard-completed; this script mounts only /data/mender
and the scratch directory, and never set WIZARD_FLAG_PATH. So the container
could not see the flag whatever the node held, every run since has stopped at
`setup_incomplete`, and the end-to-end verification this repo leans on has been
quietly verifying nothing for three weeks.

Found by running it. It mounts /data/retina-gui read-only now and points the
env var at the node's own flag, so the gate is exercised rather than bypassed.

Its consent handling was stale in the other direction. The script synthesised
three records on the grounds that "the retina-gui wizard persists none", which
stopped being true at retina-gui v0.7.0; owl-ded9 has held all three since
2026-08-16. It now uses the node's own file when there is one and says which it
used, falling back to synthetic only when there is not. Nothing worth trusting
comes from a run that quietly replaced the thing under test.

UNSITE=1 nulls the geometry in the scratch copy so the unsited path is
reachable on a node that has a tower. The node's own config.yml is never
written to; this script still touches nothing outside /tmp on the node, and
live-stalled.sh remains the only one that writes to it at all.

summarise_run.py printed rx_alt_ft and nothing else of the geometry, which was
reasonable when the geometry was always present and is not now that seven
nulls are the interesting case. It prints both positions and the callsign.

Verified against owl-ded9: unsited, registered, config accepted, 3 heartbeats
and 30 frames at 0.9 Hz with nothing dropped, seven nulls on the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A refused registration went into `errors[]` and nowhere else. Nothing reads
that list: retina-gui renders `detail` verbatim for any state outside
{registering, awaiting_config, starting}, and ignores the rest. `unregistered`
is not one of those three, so the channel was open the whole time and we were
writing null into it.

owl-ded9 is what this costs. It has been refused 232 times over nine days with
a 400 naming `tx_lat`, which is the actionable kind: its receiver and
illuminator are at the same point, because the tower's coordinates were typed
into the receiver fields at the wizard's location step. Everything needed to
say so was in hand. Its owner saw a blank line.

Three refusals, three sentences, because they point at different people:

  400          names the field and says where to correct it. It cannot clear
               without a local edit, so there is nothing to wait for.
  403 / 429    deliberately opaque, and the *normal* answer while Mender
               acceptance propagates.
  5XX / no     the network rather than the node. Sending an owner to check a
  answer       configuration that is fine wastes the one thing they will do.

The 403 is shown from the first refusal rather than after a threshold. A delay
would keep a newly flashed node quiet, but it would also leave a genuinely
stuck one looking healthy for as long as the threshold lasts, and the count of
attempts is not the thing an operator needs. The reassurance goes in the
wording instead: that sentence leads with "this is normal on a newly flashed
node and clears on its own once Mender has accepted it".

`unregistered` gains a baseline sentence too. It was one of three states with
none, so the window before the first attempt was blank as well.

Placed above the unsited line in the precedence order: a node the server will
not accept has a more urgent thing to say than one it would accept but cannot
place. Cleared on success, because a stale refusal on a working node is worse
than none.

The retry loop is deliberately unchanged. A 400 keeps retrying at the 30 minute
ceiling and the payload is rebuilt from the files each pass, so correcting the
configuration recovers with no restart. That spends registration allowance on a
node that is already broken, which is worth less than recovering unattended.

Verified against owl-ded9's own configuration through the tunnelled mock: the
real 400, and `detail` naming tx_lat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit fixed live-service.sh and stopped there. The same bug is in
live-failures.sh, live-stress.sh and live-stalled.sh: none of them set
WIZARD_FLAG_PATH or mount /data/retina-gui, so since 1f1b990 landed on
2026-08-26 every one of them has held at `setup_incomplete` and tested nothing
past the first gate. Found by going to run them.

Four scripts, one bug, and it was worth checking rather than assuming the one I
happened to run was the only one carrying it.

UNSITE=1 reaches live-failures.sh and live-stress.sh too, for a reason worth
recording. owl-ded9's own configuration puts its receiver and illuminator at
the same point, so the server refuses its registration with `400: tx_lat` and
the node never gets a token. Both scripts need a registered node to test
anything at all: run against that config, live-failures.sh produced two
registration attempts and none of the five refusal paths it exists to drive.
Nulling the geometry in the scratch copy is honest rather than a workaround,
because unsited is a real fleet state, and it leaves the node's own config
untouched as every script here does except live-stalled.sh.

Verified on owl-ded9 against the tunnelled mock, all four now reaching what
they were written for:

  probe       stages 1 and 2, every check passed against live config and frames
  service     sited config refused as production refuses it, with the new
              detail naming tx_lat; unsited registers and streams
  failures    409, 429, pause, resume and 401 all driven; states error, paused
              and streaming all seen; detections stop dead after the 401 while
              heartbeats continue
  stress      one registration across three processes, three boot_ids each
              restarting seq from 1 with nothing dropped, and a node with an
              unreadable config still beating with config_version null and the
              YAML error in errors[]

No required-nullable key was dropped from any payload across the whole run,
which is the fourteen-field rule holding on real data.

live-stalled.sh is fixed the same way but not run: it stops blah2 on a live
radar node and needs its own sign-off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes PUT /v1/nodes/contact, whose retina-gui half landed today. Five
optional fields, read from /data/retina-gui/telemetry-contact.json and sent
unchanged: the stored shape mirrors the wire's NodeContact one-for-one, so
stage 2 here is the thinnest builder in the service and converts nothing.

The discipline is all in when *not* to send.

**A node with nothing to report never calls the endpoint.** The spec says so,
and it is not a nicety: an empty document is a valid payload that clears
whatever the server holds. That is right for an owner who deleted their details
and wrong for one who never gave any, and the two are told apart by whether
anything has been sent this process. So an absent file is a complete answer
rather than a gap, and the ordinary node sends nothing at all, forever.

**On local change only.** Nothing on the server asks for this and no response
marks it stale, so a change in the file is the only thing that sends it.
Comparison is frozen-dataclass equality, the same mechanism as NodeConfigRaw.
Clearing is a change like any other, so an owner deleting their details
produces an empty document that travels and removes them server-side.

It rides the config loop rather than a sixth thread of its own. Both documents
are noticed the same way, by re-reading a file retina-gui wrote on a timer,
because nothing in this stack pushes at us. It runs *before* the configuration
and is not guarded by it: a node whose config.yml is unreadable can still say
who owns it, and that is exactly the node somebody needs to ring.

**A refusal goes to errors[] and never to the status document's detail.**
Unlike a refused registration, a rejected contact document breaks nothing: the
node registers, streams and beats exactly as before, and the only loss is a way
to ring the owner. `detail` is for the things stopping a node working, and
filling it with something that is not would train an operator to ignore it.

Nothing is substituted, the same discipline as the consent records and the beam
geometry, and more pointedly here: these values reach a person, so inventing
one would put a wrong name or a wrong number against a real node. A non-string
is dropped rather than coerced, because str(441234567890) is a phone number
nobody typed.

Reading never raises. An unreadable contact document is indistinguishable in
consequence from an absent one, and neither is a reason to stop streaming.

The mock gains the endpoint, its validator and its own refusal slug:
`invalid_contact`, not `invalid_config`, which is why v1.2.0 split them. The
empty string is refused while null is accepted, because the schema is explicit
that a node with nothing to say has exactly one way to say it.

Verified on owl-ded9 through the tunnelled mock, both halves: with the node's
own state, which has no contact document, nothing was sent across the whole
run; with CONTACT=1, one PUT carrying all five fields and no repeat over six
poll ticks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UNSITE=1 reached the other three scripts and not this one, which made it the
only script in tools/ that still could not run on owl-ded9. That node's own
configuration puts its receiver and illuminator at the same point, so the
server refuses its registration and it never streams.

`stalled` is "healthy client, radar produced and then stopped", so it is only
reachable from a node that registered and streamed first. Without a
registerable configuration this script stopped blah2, waited, restarted it, and
reported `reached stalled: NO` with nothing having gone wrong: it silently
proved nothing while looking like it had run.

Found by running it. First confirmation of `stalled` on real hardware:

  10:38:06  state=streaming  blah2=up
  10:38:16  state=stalled    blah2=down
  ...seven beats...
  10:39:26  state=streaming  blah2=up

Which is the distinction v1.1.1 added the value for. The radar stopped, and the
node said so against the radar rather than raising `error` against itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deploy fragment described /data/retina-gui as being there "for the
telemetry consent record". It now carries three things this service reads: the
consent records, the setup-wizard flag and the contact document.

Worth stating because the useful fact is the absence of work: the contact
document landed in the same directory, so PUT /nodes/contact needed no compose
change at all. A reader comparing this fragment against the contact feature
would otherwise go looking for a mount that was never required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Purple10101
Purple10101 merged commit 4facb14 into main Sep 16, 2026
2 checks passed
@Purple10101
Purple10101 deleted the 20260916-adopt-spec-v122 branch September 16, 2026 17:42
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