Carry the node_ref across the detection mirror - #373
Conversation
A node whose detections are mirrored in from another environment has no registry row here, so the boundary could not name it and dropped it: on the test droplet that is all twelve real nodes, whose detections arrive over the bulk endpoint from production rather than from an enrolment here. The ref travels with the detections instead. Production resolves it from the row it owns and sends it alongside; the bulk endpoint records it on the connected-node entry; the resolver reads it only when the local registry has nothing, so a real row always wins. Nothing is minted locally, which keeps refs to one per node across environments rather than one per server, and the value is only as trusted as the API key the bulk endpoint is gated on. The reverse direction stays local: a mirrored ref does not resolve back to its node id, so the per-node path-parameter routes still answer only for nodes this server registered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A review of the previous commit found two faults in it. The ingested node_ref was bounded only by length, and a node id is short enough to fit, so a bulk entry naming one would have had it published as that node's public handle: a raw node id on the public wire, reached through the fallback that exists to keep one off it. It now takes the same annotation a minted ref must satisfy, so a value of the wrong shape is refused at the edge. The ref was also recorded only where the node registers, and a ref arriving against an entry the server already holds moves neither `known` nor `changed`. That is the rolling upgrade this feature was written for, so it would have done nothing until a restart. Both branches record it now. Two smaller things while here: the collision case is refused rather than left to publish two nodes under one handle, and the lookup no longer takes connected_nodes_lock, which had put it on the 1 Hz publication path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ruff's isort rule (I001) rejects the placement; the lint gate would have failed CI. Fold this into the commit above before merging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Merge blocker: the trust model here rests on
|
| environment | RADAR_API_KEY |
|---|---|
| retina-prod | set |
| retina-staging | no such line in backend/.env |
| retina-test | not checked |
Staging matches ticket 86cb93r0d, which already records it unset there.
Nobody had connected that ticket to this feature; they are the same exposure.
Staging's practical risk is low today because it runs only synthetic nodes,
which short-circuit in is_synthetic_node before the mirrored lookup is
reached.
retina-test is the environment that actually exercises this path, since it
is the one receiving mirrored real nodes from prod, and it is the one I could
not check. That is the answer this PR needs before it merges: read
/opt/retina-server/backend/.env on that droplet, and if RADAR_API_KEY is
unset, set it before merging rather than after.
Worth linking 86cb93r0d to whichever ticket is raised for the underlying
regression.
The collision check lived in _record_mirrored_ref, but the registration branch had already written entry.node_ref straight into the new connected-node entry before calling it, and the guard refuses by returning rather than by clearing. So a colliding ref was seated anyway, and the check only ever bit on the already-known branch. Registration is first contact for a mirrored node, which made the unguarded path the common one rather than an edge case. Dropping node_ref from the dict literal leaves _record_mirrored_ref as the sole writer on both branches, so the two paths give the same guarantee. Two docstrings also claimed more than the code does. The guard consults the local registry only, so two mirrored nodes both claiming one ref are not caught; and _mirrored_ref justified its lock-free read on entries being replaced wholesale, which is not true of the single-key writes around it. The read is still safe, for a different reason, now stated. Found in review of this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @Babissimo's task in 1m 40s —— View job Review: Carry the node_ref across the detection mirror (re-review after push)
The collision-guard bug from the previous review is fixed. Commit The commit also tightens two docstrings that had overclaimed:
Nothing else changed in this push besides this fix and its test, so the rest of the design from the original review stands: fail-closed publication is preserved, the registry still wins over a mirrored ref ( Not re-verified here: I could not execute Outstanding, not a code issue: the merge-blocker raised by the PR author ( |
|
Correction to the "Before merging" note above: it says the third commit wants
Both should be folded into The merge blocker above still stands: |
Merge blocker resolved: retina-test has
|
| environment | RADAR_API_KEY |
|---|---|
| retina-prod | set |
| retina-test | set |
| retina-staging | no such line in backend/.env |
retina-test is the environment that actually receives mirrored real nodes, and
its bulk endpoint is gated, so the trust model this change depends on holds
where the path is used. The blocker I raised above is cleared.
Staging remains unset, which is ticket 86cb93r0d and not a blocker here: it
runs only synthetic nodes, and those short-circuit in is_synthetic_node
before the mirrored lookup is reached. It becomes one the day staging carries a
real node, so the ticket is worth keeping open on that basis rather than as
tidiness.
One adjacent thing checked, since retina-test is deliberately a free-for-all
deploy target: just deploy-test rsyncs with --delete, but backend/.env is
an excluded path and the recipe deliberately omits --delete-excluded, so an
ad-hoc deploy cannot strip the key off the droplet. The gate survives the
deploy pattern that environment is for.
Why
The test droplet is publishing a fleet that is half fiction. Measured 2026-09-14:
Ten real nodes are connected and active, and not one of them reaches the map.
test-map.retina.fmshows the 50 synthetic nodes and none of the real ones.services/node_refs.public_identityis fail-closed by design: a real node withno registry row resolves to
Noneand its entry is dropped, because publishingthe private id as a fallback is the exact failure the boundary exists to
prevent. Dropping them is correct given the inputs. The gap is upstream: the
test droplet receives real nodes from prod over the detection mirror, those
nodes have no registry row locally, and nothing carries their ref across.
This was the final piece of the node_ref publication plan. It was written and
reviewed alongside #349 but was not part of that merge, which is why the
feature is absent from
main.The change
detection_mirror.build_batchattachesnode_refto an entry whennode_refs.ref_for(node_id)resolves.BulkNodeEntrygainsnode_ref: NodeRef | None, using the canonicalannotation with its
^(nde|sim)[0-9a-z]{12}$pattern rather than a loosestr. An unpatterned field long enough to hold a node id would let a callerinject one onto the public wire through the very fallback meant to keep it
off.
_record_mirrored_ref. A ref arriving against anentry the server already holds moves neither
knownnorchanged, sorecording it only at registration would do nothing until a restart, which is
precisely the rolling-upgrade case this is for.
owner_identitybecomesref_for(node_id) or _mirrored_ref(node_id), so areal registry row always wins.
One subtlety worth a reviewer's eye: the fallback is applied in
owner_identity, notpublic_identity. It reaches the public path onlybecause the latter delegates to the former. If either function is restructured,
that delegation is what makes this work.
Verification
ruff checkandruff format --checkare clean across all seven files.I did not run the backend tests locally: this worktree has no venv, and
borrowing another checkout's resolves imports to the wrong tree through its
editable-install paths. The three test files ship with the change and CI runs
the full matrix, so that is the gate. The tests cover the public path including
the fail-closed case:
test_a_mirrored_ref_is_published_when_the_registry_has_none,test_without_one_the_node_is_still_dropped,test_the_registry_wins_over_a_mirrored_ref, andtest_a_mirrored_node_reaches_the_published_feed.Before merging
The third commit, "Sort the NodeRef import into the first-party block", is a
correction to the commit below it and should be squashed into it rather than
merged as its own step. It exists separately only because the amend was
unavailable to me. The placement it fixes fails
ruff's I001, so the branchwould have gone red in CI without it.
Note
routes/radar.pydeliberately publishes"node_ref": Nonefor the defaultpipeline. That is correct: the default pipeline is a process-wide fallback with
no registry row, so it genuinely has no ref, and the key is present so consumers
read an explicit null rather than a missing field. It should not be "fixed" by
substituting its node_id.
There is no ClickUp ticket for this yet.
🤖 Generated with Claude Code