Skip to content

fix(ingestor): recompute route_mask inline when merging an uncomputed side (#287) - #295

Merged
dborup merged 6 commits into
masterfrom
codex/issue-287-merge-route-mask-null
Oct 6, 2026
Merged

dborup merged 6 commits into
masterfrom
codex/issue-287-merge-route-mask-null

Conversation

@dborup-agent

@dborup-agent dborup-agent commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #287

Problem

mergeTransmissions in cmd/ingestor/hash_migrate.go folded the loser's
route_mask into the survivor with
COALESCE(route_mask, 0) | COALESCE(loser.route_mask, 0), keeping NULL only
when both sides were NULL. But a NULL route_mask means "not computed
yet", not "no routes". When only one side was computed, COALESCE(…, 0)
injected 0 for the uncomputed side and the result was non-NULL. The
route_mask backfill only recomputes NULL rows (WHERE route_mask IS NULL),
so it then skipped the merged row and the uncomputed side's route bits were lost
for good. The issue's repro ended with DIRECT only, although the packet was
also heard as FLOOD.

Fix: recompute the mask inline, in the merge transaction

  • Both sides known: each stored mask already encodes that side's
    route_type and every observation it ever held, so the merged mask is the
    plain OR oldMask | loserMask. There is no COALESCE on this path, so the
    original bug cannot come back through it.

  • Either side NULL (including both): the merge computes the mask in Go,
    inside the same write transaction, and writes it as one bound value:

    mergedMask = winner.route_type bit
               | loser.route_type bit
               | header bits of the observations parented on the winner after the move
               | winner.route_mask   (if known)
               | loser.route_mask    (if known)
    

    The first three terms are the Same advert seen on flood and zero-hop routes keeps the route of the first inserted observation #89 lower bound that the backfill would
    produce, plus the loser's route_type. They are computed by the new
    recomputeMergedRouteMask. The stored masks are ORed in by the caller.

The result is always non-NULL, so the backfill leaves the row alone. If the
survivor's mask changed, or was NULL before the merge, the merge logs a
route_mask_changes row, which lets a running server pick up the new mask
without a restart.

Why not keep the mask NULL and defer to the backfill? That was round 1,
and the round-1 review showed it still loses a bit in the other direction. The
backfill rebuilds only from the survivor's route_type and the observations
that survive the merge. A bit that the computed side stored from a frame no
surviving observation can rebuild would be thrown away. One example is an
observation that idx_observations_dedup drops while the loser's observations
are re-parented. Keeping both stored masks in the OR keeps those bits.

The per-observation header parse that the backfill and the merge share is
extracted into routeMaskBitFromHeader (cmd/ingestor/route_mask_backfill.go),
so both read a frame header the same way.

Not too broad. Every term is evidence of a frame actually received for this
content hash. packetpath.RouteMaskBit returns 0 outside route types 0..3, and
a missing or non-hex header contributes nothing. | is idempotent. The merged
mask is a superset of both stored masks, so a merge never shrinks a stored mask.

Change-log rows. The row is logged whenever the survivor's mask was NULL,
even when the merged mask is a known 0. On a pre-#89 database, where every
mask is NULL, the one-time migration therefore logs one row per merge. Master
logged none. The count is bounded (one per merge), the server consumer reads at
most routeMaskChangesBatch rows per tick, and the rows are pruned.

Cost. For each merge, the both-known path still issues 2 point
SELECT route_mask queries, the same number as before; the post-UPDATE
re-read is gone. The NULL path adds 2 point SELECT route_type queries and
one indexed scan of the survivor's observations
(idx_observations_transmission_id). There is no per-observation query. The
round-2 reviewer measured 500 both-NULL merges in one batch at 89 ms on
master and 97 ms on this branch (≈ +16 µs per merge).

Requirement 2: other columns in the merge

I reviewed every column mergeTransmissions writes:

Column(s) Merge rule NULL-means-unknown risk?
first_seen, last_seen MIN / MAX No: NOT NULL, never unknown
route_mask union, recomputed inline when a side is NULL Yes, fixed here
fill columns (route_type, payload_type, payload_version, decoded_json, from_pubkey, channel_hash, scope_name) COALESCE(survivor, loser) No

The fill columns use COALESCE(survivor, loser): they fill an unknown (NULL)
survivor value from a known loser value and never fabricate a sentinel. A
column whose NULL means "pending" therefore keeps that meaning: if both sides
are NULL, the result stays NULL. Only route_mask was merged as a union with a
COALESCE(…, 0) sentinel while also being owned by a NULL-gated backfill, so it
was the only affected column. No other column changed.

Repairing already-merged rows on existing deployments

content_hash_formula_v1 has already run on live deployments and is recorded as
done, so it will not re-run. There is no automatic repair, because a safe,
cheap, targeted one does not exist:

  • Not targetable. A row corrupted by this bug holds a partial non-NULL
    mask that is indistinguishable from a legitimately complete one. Nothing
    selects only the affected rows.
  • A blanket forced recompute is not safe. Setting every route_mask back to
    NULL and re-running the backfill would shrink masks that live ingest
    legitimately grew. The backfill is explicitly a lower bound: it does not
    re-invent a route variant whose frame no longer exists in any
    observations.raw_hex. In live ingest, route_mask is monotonic: bits are
    OR-ed in and never cleared. A blanket recompute would trade the hash migration: merging a transmission with a NULL route_mask turns it into 0, so route flags are lost permanently #287 loss for
    a broader one. It would also rewrite the whole table, so it is not cheap.
  • Scope is small and bounded. The bug only affected rows that collided
    during the one-time migration and had exactly one side NULL.

The fix prevents the loss on any deployment that has not run the migration yet.
If the formula ever changes again, the migration gets a new name and re-runs,
and merges are then correct. Writes still live only in the ingestor, and
cmd/server is untouched.

Tests

All seven tests are in cmd/ingestor/hash_migrate_route_mask_287_test.go. Each
one runs the real migrateContentHashes merge and, where relevant, the real
backfillTxRouteMask afterwards.

# Test Case Expected
1 TestContentHashMigration_MergeRecomputesUnionWhenLoserUnknown_287 survivor computed DIRECT, loser NULL heard FLOOD (the issue's repro) DIRECT|FLOOD inline, one route_mask_changes row with the full mask, unchanged by the backfill
2 TestContentHashMigration_MergeRecomputesUnionWhenSurvivorUnknown_287 reverse direction: survivor NULL heard FLOOD, loser computed DIRECT DIRECT|FLOOD inline
3 TestContentHashMigration_MergeKeepsStoredBitsOfDedupDroppedObservation_287 loser's stored DIRECT bit whose only frame is dedup-dropped in the move DIRECT|FLOOD, kept through the backfill
4 TestContentHashMigration_MergeKeepsFillColumnsNullWhenBothUnknown_287 requirement 2: both sides leave fill columns NULL fill columns stay NULL
5 TestContentHashMigration_MergeRecomputesBitFromSurvivingObservationHeader_287 the only DIRECT evidence is a surviving observation header DIRECT|FLOOD inline and after the backfill
6 TestContentHashMigration_MergeRecomputesBitFromLoserRouteType_287 the only DIRECT evidence is the loser's route_type (its observation has no raw_hex) DIRECT|FLOOD inline and after the backfill
7 TestContentHashMigration_MergeBothRouteMasksUnknown_287 (subtests with evidence, without evidence) both sides NULL known DIRECT|FLOOD / known 0 written by the merge, exactly one change row, unchanged by the backfill

Tests 1–3 were added red-first in rounds 1–2. Test 4 is a guard and was green
before and after, because requirement 2 needs no code change. Tests 5–7 were
added in round 3 to pin the code shipped in round 2. They guard existing
behaviour, so their red state is shown by the mutants below. Against master's
merge code, tests 1, 2, 3, 5, 6 and 7 fail and test 4 passes.

Mutants

Each mutant was applied to a copy of the merged tree, the tests were run, and
the copy was discarded. The round-3 rows, plus master's merge code, were run
against the full cmd/ingestor suite (go test ./...). The round-1 and
round-2 rows are taken from the reports of those rounds.

Round Mutant Killed by
1 master's COALESCE(route_mask, 0) | COALESCE(…, 0) merge tests 1, 2, 3, 5, 6, 7
1 a fill column coalesced to a literal (COALESCE(scope_name, '')) test 4
2 M1: recompute but drop the stored-mask OR test 3
2 M2: recompute only when the loser is the NULL side test 2
2 M3: merge logs no route_mask_changes row test 1 (+ ..._ConvergesInTheIngestor_215)
3 MX1: read only the winner's route_type test 6
3 MX2: skip the observation scan test 5
3 MX3: scan the loser's observations (empty after the move) test 5
3 MX4: routeMaskBitFromHeader always returns 0 test 5 (+ the existing route_mask_backfill tests)
3 M5: a both-NULL merge stays NULL (master's behaviour) test 7
3 M6: no change row when the survivor's mask was NULL test 7

🤖 Generated with Claude Code

…287)

A NULL transmissions.route_mask means "not computed yet", not "no routes".
mergeTransmissions OR-ed COALESCE(route_mask, 0) from each side, so when only
one side had a computed mask the result was non-NULL. The route_mask backfill
only recomputes NULL rows, so it then skipped the merged row and the uncomputed
side's route bits were lost permanently.

Keep the merged route_mask NULL whenever either side is NULL, so the backfill
recomputes it from the survivor's full (merged) set of observations. In the
non-NULL branch both sides are known, so a plain OR is exact.

The other merged columns were reviewed: first_seen/last_seen are NOT NULL
(MIN/MAX), and the fill columns use COALESCE(survivor, loser), which fills an
unknown survivor value from a known loser value and never fabricates a sentinel
that would block a backfill. Only route_mask had the pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#295 #287 — head a558c8c

Status: Done — fix + 2 tests pushed, draft PR open, all CI jobs green (no flakes, no re-run).

Requirements

# Requirement Implementation Test (red→green) Mutant (killed)
1 NULL side ⇒ merged route_mask stays NULL so backfill recomputes mergeTransmissions now uses CASE WHEN route_mask IS NULL OR loser IS NULL THEN NULL ELSE route_mask | loser END (hash_migrate.go:327). Chose "keep NULL + let the #89 backfill recompute from the merged observations" over an inline recompute: the loser's observations are already re-parented onto the survivor, so the existing backfill produces the full union without duplicating its route-type + per-observation header logic. [K][T] ..._MergeKeepsRouteMaskNullWhenLoserUnknown_287: survivor DIRECT + loser NULL(FLOOD) → migrate → backfill → DIRECT|FLOOD. Red: merged mask was 4, backfill skipped. Restore COALESCE(route_mask,0) | COALESCE(…,0) + AND → final 4 ≠ DIRECT|FLOOD, test fails. [T]
2 Check other merged columns for the NULL-means-unknown pattern Reviewed all: first_seen/last_seen are MIN/MAX of NOT NULL cols; fill columns (route_type,payload_type,payload_version,decoded_json,from_pubkey,channel_hash,scope_name) use COALESCE(survivor, loser) which fills unknown-from-known and never fabricates a sentinel. Only route_mask (a union with a COALESCE(…,0) sentinel that is also backfill-gated) had the bug. No other column changed. [A][K] ..._MergeKeepsFillColumnsNullWhenBothUnknown_287: both-NULL fill columns stay NULL after merge. Guard — green before and after, because req 2 needs no code change (see note). Change a fill col to COALESCE(scope_name,'') → both-NULL merge yields '', test fails. [T]
3 survivor DIRECT(computed) + loser NULL(FLOOD) → merge → backfill = DIRECT|FLOOD; COALESCE mutant dies Covered by the req-1 test end-to-end (runs the real migrateContentHashes merge then backfillTxRouteMask). [T] same as req 1 same as req 1
4 Say whether already-merged rows on existing deployments can be repaired Documented in the PR body. Not implemented — no safe/cheap/targeted repair exists: affected rows hold a partial non-NULL mask indistinguishable from a complete one (not targetable); a blanket route_mask=NULL+rebackfill would shrink masks that live ingest legitimately grew from now-vanished frames (backfill is a documented lower bound; mask is monotonic) and is a full-table rewrite (not safe, not cheap). Scope is small (collision AND exactly-one-side-NULL during the one-time migration). [A][K] n/a (analysis; no write added) n/a

Note on requirement 2's test

Req 2 is an investigation whose conclusion is "no other column needs the change", so a red-before test is not possible for it. The provided test is a guard (green before and after) that locks the invariant and kills a plausible sentinel-injecting mutant. All other points have a genuine red→green test. [A]

CI — per job (run 37404626968)

Job Result
✅ Go Build & Test pass (15m31s)
🎭 Playwright E2E Tests pass (25m8s)
🏗️ Build & Publish Docker Image pass (1m0s)
📦 Release Artifacts skipping (fork guard)
🚀 Deploy Staging / 📝 Publish Badges / Summary skipping (fork guard)

No job failed; the known-flaky #271 did not trigger, so no re-run was needed. [T]

Guardrails [T]/[A]

  • cmd/server untouched (git diff --name-only origin/master → only cmd/ingestor/hash_migrate.go + new test). [T]
  • No new map[string]interface{} outside tests (grep on the + diff → none). [T]
  • No hardcoded colors; scripts/check-xss-sinks.sh --diff origin/master → clean (no public JS/HTML changed). [T]
  • Fork guards unchanged: github.repository == 'Kpa-clawbot/CoreScope' counts 9 in deploy.yml, 1 in release-fast-path.yml; workflow files not in the diff. [T]
  • Local before push: go test ./... (ingestor, 107s), sh test-all.sh (221/221), node test-frontend-helpers.js (709/709) all green. [T]

Remainders

  • No E2E is affected: the change is a one-time ingestor migration/merge SQL path with no server/frontend surface, so no new/changed E2E was added; existing Playwright suite still passes. [A]
  • Existing-deployment repair deliberately not shipped (req 4 above). [A]

@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Review — CS-pve-agent3 PR#295 — head a558c8c

Dom: REQUEST CHANGES

The fix fixes the reported repro, and the guardrails are clean. But deferring to the backfill adds a new permanent route-bit loss of the same kind as #287: a stored, known bit of the computed side is thrown away when the backfill cannot rebuild it from the observations that survive the merge. The reverse direction (survivor NULL, loser computed) is also untested, and a mutant that breaks it survives the whole suite.

Evidence tags: [T] = I ran it; [A] = analysis/code reading; [K] = taken from the PR/issue/CI as stated.

Findings

# Severity Finding Evidence
1 Medium When exactly one side is NULL, the merged mask becomes NULL and the backfill rebuilds it only from the survivor's route_type and the observations that survive the merge. The computed side's stored bits are thrown away. That is fine if the backfill can rebuild them, but they are lost for good when their frame is gone. A concrete case is an observation that idx_observations_dedup drops during the merge (same observer, same path_json). Repro: survivor NULL / route_type FLOOD / obs o1 [] FLOOD frame. Loser holds the current hash, route_mask = DIRECT, obs o1 [] DIRECT frame. After migrate and backfill, head = 2 (FLOOD), master = 4 (DIRECT), correct = 6. A zero-hop advert and a flood advert of the same payload, both heard directly by one observer, form exactly this pair (the "mixed" ADVERT class). Before, the known side was kept and the NULL side was lost. Now it can be the other way round. Head never ends with fewer bits than master did, but it still loses a bit permanently here, and it is the same kind of loss as #287. It also contradicts the PR's own requirement-4 argument: "the backfill is a lower bound … a recompute would shrink masks that live ingest legitimately grew". That argument applies to every merged row with exactly one NULL side. [T] reviewer test TestReview287_KnownBitsOfDedupDroppedObservation: fails on head (after backfill = {2 true}, want 6) and on master ({4 true})
2 Low (test gap) Only the "loser NULL" direction is tested. The "survivor NULL, loser computed" direction is probably the common one in practice: the stale pre-#89 row has the lowest id, so it survives, and it collides with a newer row written under the current hash, which already has a mask. Head handles this case correctly. But mutant M1 (guard only the loser side: CASE WHEN loser IS NULL THEN NULL ELSE COALESCE(route_mask,0) | loser END) survives the PR's tests and every existing ContentHash/RouteMask/FillableColumns test. [T] M1 table below; reviewer test TestReview287_SurvivorNullLoserComputed kills it (red on master: merged mask = 4, want NULL; green on head)
3 Low Running-server staleness. The merge now writes NULL without a route_mask_changes row, and the backfill writes the full mask later, also without a row (by design, see TestRouteMaskChange_BackfillWritesNoEvents). A running server whose in-memory copy of the survivor is already known never re-queues it. Its in-memory merge (cmd/server/hash_migrate.go, if loser.routeMaskKnown { … }) also keeps the survivor known when the loser is unknown. So in the PR's own scenario the server keeps DIRECT until it restarts. This is still better than master, where the DB itself stayed wrong. Option 2 below fixes it as well, because the merge then has a non-NULL mask and logs the change row as it does today. [A] not exercised against a running server
4 Nit Code comment and file header say the backfill recomputes "from the survivor's full set of observations, which now holds both sides'". That is not true for observations that dedup drops, which the file header itself says are dropped. [A]
5 Nit (optional) In SQLite x | NULL is NULL, so route_mask = route_mask | (SELECT …) behaves the same as the new CASE. The equivalent mutant passes every test. Keeping the explicit CASE for readability is fine. [T] M_equiv below

Suggested fix for 1 (and 3). This is the issue's second option. In the exactly-one-NULL branch, compute the mask inside the merge transaction:
known side's mask | route_type bit(s) | header bits of the merged observations.
To avoid the duplication the PR worries about, move the per-transmission part of backfillTxRouteMaskBatch (route-type bit plus observations.raw_hex header bits) into one helper that both callers use. The OR with the known mask keeps every bit that was stored; only the uncomputed side's unrebuildable frames are lost, and no design can recover those. Because the result is non-NULL, the existing route_mask_changes logging also tells running servers. If the maintainer prefers to accept the trade-off, the PR should at least say it plainly, pin it with a test, and fix the comments (finding 4).

Answers to the review points

  1. Requirements and tests.
    • NULL side ⇒ merged NULL so the backfill recomputes: met. TestContentHashMigration_MergeKeepsRouteMaskNullWhenLoserUnknown_287 is red on master (merged route_mask = 4, want NULL) and green on head and on the merged tree. [T] It only covers the loser side; see finding 2.
    • Check the other merged columns: I agree with the analysis. first_seen/last_seen are MIN/MAX of NOT NULL columns, and the fill columns use COALESCE(survivor, loser), which never invents a value. [A] The guard test is green before and after, as the PR says. [T]
    • DIRECT + NULL(FLOOD) → DIRECT|FLOOD, and the COALESCE mutant dies: met. [T]
    • Can already-merged rows be repaired? Answered in the body. I agree a targeted repair is impossible, because a partial mask looks like a complete one. [A] The same "lower bound" argument, though, weighs against the deferral design itself (finding 1).
  2. Mutants. Own mutants M1–M4 below. M1 survives the PR suite (finding 2).
  3. Edge cases. Three tested: A (reverse direction) is correct on head but untested in the PR. B (dedup-dropped known side) is finding 1. C (both NULL) is correct on head.
  4. Scope. The diff is only cmd/ingestor/hash_migrate.go plus a new test file. The only behaviour change is the merge rule, which covers finding 1. No write moved out of the ingestor, cmd/server is untouched, public/ is untouched, and there are 0 new map[string]interface{}. [T]

Tests and mutants

Trees. git archive of head a558c8c6 and of the merged tree a89c4513, which is git merge-tree --write-tree of origin/master 4f1de049 and head. Head is unchanged on the remote before and after the review. [T]

Run (merged tree unless noted) Result
PR's two _287 tests, head tree PASS [T]
PR's two _287 tests, merged tree with master's hash_migrate.go route-mask test FAIL (merged route_mask = 4); fill guard PASS (expected) [T]
cmd/ingestor go test ./... ok (630.8 s) with -timeout 20m as in CI [T]. A first run with Go's default 10 min timeout, under load from my parallel mutant runs, hit test timed out after 10m0s. Note that the plain go test ./... named in AGENTS.md sits close to that limit on this host.
cmd/server go test ./... ok (716.6 s) with -timeout 20m [T]. The first run, under load, failed TestHandleNodePaths_PrefixCollisionExclusion with 503 index loading. Run alone (-run …$) it fails the same way every time, but it passes inside the full suite. That points to an order-dependent test. cmd/server is byte-identical to master, so this is not caused by this PR.
sh test-all.sh 222 passed, 0 failed [T]
node test-frontend-helpers.js 709 passed, 0 failed [T]
E2E against a local Go server on e2e-fixture.db, prepared as in CI (freshen, Kpa-clawbot#1486 seed SQL, corescope-migrate, seeds 2073, 199 and 245): test-issue-2073-recent-adverts-e2e.js (route_mask consumer) 10/10 [T]
same: test-issue-245-advert-intervals-e2e.js 7/7 [T]
same: test-e2e-playwright.js 131 passed, 3 skipped, 1 failed: Version info lives on Perf dashboard, not in navbar (#navStats wait, 10 s timeout) [T]. It fails the same way with the bundled and the system Chromium, and the stock fail-fast run stops there; the 131/135 count comes from a scratch copy with fail-fast disabled. On its own, #navStats fills in about 120–400 ms, so this looks like local state or the environment. The server binary sources and public/ are byte-identical to origin/master (0-line diff), and the CI Playwright job passed on this head, so it is not caused by this PR [T][K].
bash scripts/check-xss-sinks.sh --diff origin/master clean, no public/ changes [T]

The PR adds no E2E. That is fine here: the change is an ingestor-only migration path, and the server never runs it during E2E. [A]

Mutants. Each was applied to the merged tree. "PR+existing" runs -run 'ContentHash|RouteMask|FillableColumns' without the reviewer tests.

Mutant PR + existing tests Reviewer edge tests
M1 guard only the loser side (WHEN loser IS NULL … ELSE COALESCE(route_mask,0) | loser) survives (all PASS) killed by A
M2 guard only the survivor side (ELSE route_mask | COALESCE(loser,0)) killed: _287 test, merged route_mask = 4 —
M3 ELSE drops the loser's bits killed: ConvergesInTheIngestor_215 (want the union 7) —
M4 drop newMask.Valid && in the change-log guard killed: _287 test, route_mask_changes logged 1 rows —
M_equiv no CASE, plain route_mask | loser survives (equivalent, see finding 5) survives

Reviewer edge tests (scratch only, not pushed):

Test master head
A: survivor NULL (FLOOD) + loser computed DIRECT holding the current hash FAIL (4) PASS (6)
B: as A, but both observations are o1 / [], so the loser's is dedup-dropped FAIL (4) FAIL (2)
C: both NULL, FLOOD + DIRECT PASS PASS

Guardrails

Not verified

  • Finding 3 was not reproduced against a running server; it comes from reading cmd/server/route_mask.go and cmd/server/hash_migrate.go. [A]
  • I did not measure how common case B is on real data. No staging or prod access was used. [A]
  • I reasoned, but did not test, that the deferred NULL can never outlive the running backfill in the same boot. Both migrations start together. The backfill re-selects route_mask IS NULL on every batch with no cursor, so a survivor that the merge set back to NULL is picked up again. A merge can only produce NULL from a NULL input, and while one exists the backfill has not finished. [A]

dborup and others added 2 commits October 6, 2026 08:23
…mputed (#287)

PR #295 review (CS-pve-agent3, REQUEST CHANGES). The first fix kept the merged
route_mask NULL whenever either side was NULL and let the #89 backfill rebuild
it. That still loses a route bit: the backfill rebuilds the mask only from the
survivor's route_type and the observations that survive the merge, so a bit the
computed side stored from a frame that no surviving observation can rebuild (an
observation idx_observations_dedup drops during the move, or a frame a later
reception overwrote) is thrown away for good — the same loss as #287, in the
other direction.

mergeTransmissions now recomputes the mask inline, in the merge transaction,
whenever either side is NULL: it ORs both sides' stored masks (keeping every bit
either side recorded) with the #89 lower bound the backfill would produce (both
sides' route_type plus the surviving observation headers). The result is
non-NULL, so the backfill leaves the row alone and the route_mask_changes row is
logged as for any grown mask, which also tells a running server (finding 3).
When both sides are already computed the merge still takes the exact OR.

The per-observation header parse the backfill and the merge share is extracted
into routeMaskBitFromHeader (finding 4). The file header is corrected to
describe the inline recompute rather than a backfill that "holds both sides'"
observations, which is not true for a dedup-dropped frame.

Tests (red before the fix): the loser-NULL and the previously untested
survivor-NULL directions both recompute the union inline and log the change
(findings 2, 3); a dedup-dropped frame's stored bit is kept across merge and
backfill (finding 1). The both-NULL fill-column guard is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#295 runde 2 — head 5e95d93

Review feedback addressed (commit 63529751; head after merge 5e95d93f).

The round-1 fix kept the merged route_mask NULL whenever a side was NULL and
deferred to the #89 backfill. The review (REQUEST CHANGES) showed that still
loses a bit: the backfill rebuilds the mask only from the survivor's
route_type and the observations that survive the merge, so a bit the computed
side stored from a frame no surviving observation can rebuild is thrown away —
the same loss as #287, in the other direction. The new fix recomputes the mask
inline, in the merge transaction, whenever either side is NULL.

Findings

  1. [Medium, blocking] Known bits of a dedup-dropped frame were lost.
    mergeTransmissions now, whenever either side is NULL, ORs both sides'
    stored masks
    (keeping every bit either side recorded) with the Same advert seen on flood and zero-hop routes keeps the route of the first inserted observation #89 lower
    bound the backfill would produce (both sides' route_type plus the surviving
    observation headers), via the new recomputeMergedRouteMask. The result is
    non-NULL, so the backfill leaves the row alone. A bit the computed side stored
    from an observation idx_observations_dedup drops during the move is now kept.
    Test (red→green): ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 —
    survivor NULL/route_type FLOOD, loser route_mask=DIRECT/route_type FLOOD with
    its only DIRECT frame dedup-dropped; after migrate+backfill the mask must be
    DIRECT|FLOOD (6). Red on head (merged route_mask = NULL, backfill → 2).
    Mutant M1 (recompute but drop the stored-mask OR) → 2, killed. [T]

  2. [Low, test gap] Survivor-NULL direction untested; mutant survived. The
    fix handles both directions symmetrically (both sides COALESCEd). New test
    ..._MergeRecomputesUnionWhenSurvivorUnknown_287 — survivor NULL (heard
    FLOOD), loser computed DIRECT holding the current hash, both observations
    surviving — asserts the union (6) is recomputed inline. Red on head
    (merged route_mask = NULL right after the merge). Mutant M2 (recompute
    only when the loser is the NULL side; otherwise copy the loser's stored mask)
    → 4, killed. [T]

  3. [Low] Running-server staleness. Because the merged mask is now non-NULL
    and differs from the survivor's original, the merge logs a route_mask_changes
    row, which the server's RefreshRouteMaskChanges consumer applies — so a
    running server that already holds the survivor learns the grown mask without a
    restart. Test ..._MergeRecomputesUnionWhenLoserUnknown_287 asserts a
    route_mask_changes row carrying the full mask (6) is logged. Red on head
    (merged NULL → 0 change rows logged). Mutant M3 (merge logs no change row)
    → killed here, and also trips ..._ConvergesInTheIngestor_215 (which expects
    the survivor's grown mask announced), confirming the change-log is
    load-bearing. [T] The server consumer path itself is unchanged and was read,
    not re-exercised against a live server. [A]

  4. [Nit] Inaccurate comments. The file header no longer claims the backfill
    recomputes from "the survivor's full set of observations, which now holds both
    sides'" (untrue for a dedup-dropped frame); it now describes the inline
    recompute. [A]

  5. [Nit, optional] CASE vs plain OR. Moot: the CASE … | … SQL is gone;
    route_mask is now computed in Go and written as a single bound value. [A]

The per-observation header parse the backfill and the merge share is extracted
into routeMaskBitFromHeader so both read a frame header identically (addresses
the review's duplication concern in finding 4). The both-sides-known path still
takes the exact OR; the both-NULL fill-column guard
(..._MergeKeepsFillColumnsNullWhenBothUnknown_287) is unchanged.

Tests

  • cmd/ingestor full suite: ok 107.7 s, exit 0 (-timeout 20m, merged tree). [T]
  • sh test-all.sh: 222 passed, 0 failed. [T]
  • node test-frontend-helpers.js: 709 passed, 0 failed. [T]
  • E2E against a local Go server (port 13900) on a freshened e2e-fixture.db
    prepared as in CI (Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed, corescope-migrate, seeds 2073/199/245):
    test-issue-2073-recent-adverts-e2e.js (route_mask consumer) 10/10;
    test-issue-245-advert-intervals-e2e.js 7/7. [T] No E2E is affected by the
    change: it is an ingestor-only one-time migration/merge path; the server never
    runs it during E2E and consumes route_mask seeded directly by SQL. [A]

Mutants (one per finding)

Mutant Change Killed by
M1 (finding 1) recompute but drop the | oldMask | loserMask stored-mask OR ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 (6→2) [T]
M2 (finding 2) recompute only when the loser is NULL; else copy the loser's mask ..._MergeRecomputesUnionWhenSurvivorUnknown_287 (6→4) [T]
M3 (finding 3) merge logs no route_mask_changes row ..._MergeRecomputesUnionWhenLoserUnknown_287 + ..._ConvergesInTheIngestor_215 [T]

Each mutant was applied to the merged tree and reverted after the run.

Guardrails

  • Diff vs origin/master is only cmd/ingestor/hash_migrate.go,
    cmd/ingestor/route_mask_backfill.go and the _287 test file. cmd/server,
    public/ and the workflow files are untouched. [T]
  • No new map[string]interface{} in the diff. [T]
  • Merge of origin/master (72cc29cf) with a merge commit; author/committer
    dborup <kontakt@meshview.dk>. No rebase, amend or force-push. [T]
  • PR stays draft. No closing keywords. [T]

CI — per job (run 37423779406, head 5e95d93f)

Job Result
✅ Go Build & Test pass (22m49s)
🎭 Playwright E2E Tests pass (23m34s)
🏗️ Build & Publish Docker Image pass (57s)
📦 Release Artifacts skipping (fork guard)
🚀 Deploy Staging / 📝 Publish Badges & Summary skipping (fork guard)

No job failed. The known-flaky #271/#301 did not trigger, so no re-run was needed. [T][K]

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Review — CS-Macmini PR#295 — head 5e95d93

Dom: APPROVE med nits

Round 2 resolves the round-1 blocking finding. I reproduced the round-1 repro on three trees and get the numbers the previous reviewer predicted: master 4, round-1 head 2, this head 6. Both merge directions now have their own test, and a mutant that breaks either one dies on its own test. The remaining items are a test gap in the new code (four mutants against recomputeMergedRouteMask survive the full ingestor suite, three of them regressing below master), a stale PR body, and two small nits.

Evidence tags: [T] = I ran it; [A] = code reading/analysis; [K] = taken from the PR, the issue or CI.

Findings

# Severity Finding Evidence
1 Low (test gap) The two evidence sources the inline recompute adds over the old code are untested. In all three new route-mask tests every expected bit is redundantly supplied by the winner's route_type plus the stored masks, so the observation scan and the loser's route_type read are dead weight as far as the suite is concerned. Four mutants survive the full cmd/ingestor suite: MX1 read only the winner's route_type; MX2 return mask, nil before the observation scan; MX3 scan the loser's observations (empty after the move); MX4 routeMaskBitFromHeader always returns 0 (killed only by the pre-existing route_mask_backfill tests — nothing on the merge path catches it). MX2/MX3/MX4 are worse than master: in a case where the survivor's own observation header is the only DIRECT evidence they yield 2 where master's deferred backfill yields 6. Fix: two more cases — one where the only evidence for a bit is a surviving observation header, one where it is the loser's route_type. [T] mutant table below; reviewer probes RX4/RX5 kill all four
2 Low (docs) The PR body still describes the round-1 design and argues against the code that actually shipped: "Keep the merged route_mask NULL whenever either side is NULL" and "Doing the recompute inline in the merge transaction would duplicate that backfill logic … for no benefit". The Tests section lists 2 tests (there are 4) and says one "asserts the merged mask is NULL before the backfill" — it now asserts the opposite. The Mutants section is the round-1 pair. The round-2 report in the comments is accurate; the body is the artefact a maintainer reads, so it should be brought in line. [K] PR body vs. hash_migrate.go:333-358 and the test file
3 Nit Both-sides-NULL is not pinned by a test. It is the one case where the merge now writes a known value where master left NULL, and the behaviour is correct (verified below), but nothing locks it. [T] RX2/RX3; no _287 test covers it (the fill-column test has route_mask = 1 on both rows, so it takes the both-known branch)
4 Nit Every both-NULL merge now logs a route_mask_changes row where master logged none. On a pre-#89 DB (every mask NULL) that is one row per merge for the whole one-time migration — 500 rows for 500 merges in my probe, vs 0 on master. It is correct and bounded (1 per merge, consumer caps at routeMaskChangesBatch = 1000 per tick, rows are pruned), but the file comment says the row is logged "like any grown mask", which undersells a mask that grew from unknown. Worth one line. [T] probe below; [A] cmd/server/route_mask.go:154
5 Nit recomputeMergedRouteMask issues one point SELECT route_type per id inside a for … range [...]int64{winner, loser} loop, and mergeTransmissions reads the two route_mask values in two more point queries. All four are the same table and could be one WHERE id IN (?, ?). Measured cost is negligible (below), so this is cosmetic. [A] hash_migrate.go:321-326 and hash_migrate.go:411-418

Answers to the review points

1. Round-1 finding 1 (Medium, blocking) — resolved. I rebuilt the reviewer's repro as an independent test that asserts only the end state (after merge and backfill), so it is comparable across trees: survivor route_mask NULL / route_type FLOOD / one FLOOD observation o1 ["aa"]; loser route_mask = DIRECT / route_type FLOOD / its only DIRECT observation also o1 ["aa"], so idx_observations_dedup drops it during the move. [T]

tree after merge after backfill
origin/master 22589f05 4 4
round-1 head a558c8c6 NULL 2
round-2 head 5e95d93f 6 6

The stored DIRECT bit survives because the caller ORs both stored masks into the recomputed lower bound. The PR's own ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 covers the same shape and is red on master (4) and on round-1 head (NULL). [T]

2. Both directions. Both have a dedicated test, and I broke each direction separately:

mutant effect killed by
MX5 survivor known / loser NULL ⇒ keep the survivor's stored mask, skip the recompute 4 instead of 6 ..._MergeRecomputesUnionWhenLoserUnknown_287 [T]
MX6 survivor NULL / loser known ⇒ copy the loser's stored mask, skip the recompute 4 instead of 6 ..._MergeRecomputesUnionWhenSurvivorUnknown_287 and ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 [T]

The round-1 mutant that survived the whole suite (guard only one side) has no surviving analogue: the code is symmetric — both masks are tested with .Valid, both route_types are read, and the recompute runs whenever either side is NULL. [A]

3. Both sides NULL. The merge no longer leaves NULL; it writes the recomputed union. Two cases, both verified against master: [T]

  • With evidence (survivor NULL/FLOOD + loser NULL/DIRECT, both observations surviving): head writes 6 in the merge; master left NULL and its backfill arrived at the same 6. Same end state, reached earlier.
  • Without any evidence (route_type NULL on both sides, no observations): head writes a known 0; master left NULL and the backfill then wrote 0 too — backfillTxRouteMaskBatch updates unconditionally (UPDATE … SET route_mask = ?), it does not skip a zero result. So the end state is identical and no row with recoverable bits is skipped: the inline recompute reads exactly the sources the backfill reads (route_type ∪ surviving observations.raw_hex headers), plus the loser's route_type, so it is a superset of the backfill's result, never a subset. [T][A]

A non-NULL 0 is in fact safer than NULL here: live ingest's stmtOrTxRouteMask is … SET route_mask = route_mask | ? WHERE id = ? AND route_mask IS NOT NULL …, so it grows a known 0 but would not touch a NULL. I verified the grow (0 → 4) on a merge-written zero. [T] The only cost is that the row leaves the backfill's WHERE route_mask IS NULL population, which is exactly what the design intends. Finding 3 is that none of this is pinned by a test.

4. Not too broad. No counterexample exists, by construction. [A] The merged mask is a pure OR of five terms and nothing else:

mergedMask = winner.route_type bit
           | loser.route_type bit
           | header bits of the observations parented on the winner after the move
           | winner.route_mask   (if known)
           | loser.route_mask    (if known)

Every term is evidence of a frame actually received for this content hash: a transmission row exists only because an observation created it, and route_type is that frame's header route. The loser is the same content hash by the merge's own premise (#215), which is also why its first_seen/last_seen/observations are folded in. packetpath.RouteMaskBit returns 0 outside 0..3, so a junk route_type contributes nothing, and RouteTypeFromRawHex returns ok=false for a missing or non-hex prefix. [A] No bit can therefore appear that no side and no observation supports. Double counting is not possible: | is idempotent, and no term is a count. Two further properties I checked: mergedMask ⊇ oldMask and mergedMask ⊇ loserMask whenever those are known, so the merge can never shrink a stored mask — which is the invariant the PR's requirement-4 "lower bound" argument rests on; and the both-known branch is a plain | with no COALESCE, so the original bug cannot come back through it. [A]

One asymmetry worth naming, not a defect: the merged row's own route_type stays the survivor's (fill columns use COALESCE(survivor, loser)), while the mask carries both sides' route bits. That is the #89 definition of the column ("bit r is set when a frame carrying raw route type r was observed for this content hash"), and the both-known path has always behaved that way, because each stored mask already encodes its own route_type. [A]

5. Performance and locks. Per merge, head reads: 2 point SELECT route_mask, and on the NULL path 2 point SELECT route_type plus one SELECT substr(raw_hex,1,2) FROM observations WHERE transmission_id = ? — index-driven via idx_observations_transmission_id. It drops master's post-UPDATE SELECT route_mask. So the both-known path is query-neutral; the NULL path adds 3 point queries and one indexed range scan. Rows read per merge = the survivor's observation count after the dedup-drop, bounded by its distinct (observer, path) pairs (#89's staging sizing: 6,526,537 obs / 496,798 tx ≈ 13 per transmission). There is no per-observation query: the only query-in-a-loop is the route_type read, with N = 2 (finding 5). [A]

Measured, because the whole batch runs in one writerMu hold (rewriteContentHashes, contentHashMigrationBatchSize = 2000): I seeded 500 colliding pairs, both sides route_mask NULL, 6 observations per side (12 surviving per merge, none deduped), and timed the migration end to end. [T]

tree 500 merges in one batch route_mask_changes rows
origin/master 89 ms 0
head 5e95d93f 97 ms 500

≈ +16 µs per merge, i.e. +9 % on a hold that is already well inside the #89 batch budget. The worst realistic case scales with observations per transmission, not with batch size squared. No long lock held in a loop; the transaction shape is unchanged. The change-row count is finding 4.

6. Requirements and tests.

#287 criterion Test Red before → green after
Either side NULL ⇒ the mask must not silently become a partial non-NULL value (keep NULL or recompute in the same transaction) ..._MergeRecomputesUnionWhenLoserUnknown_287 master 4 → head 6 [T]
Survivor computed DIRECT + loser NULL (heard FLOOD) → merge → backfill = DIRECT|FLOOD same test (runs the real migrateContentHashes, then backfillTxRouteMask) master 4 → head 6, unchanged by the backfill [T]
The mutant that restores COALESCE(…, 0) must fail master's own hash_migrate.go dropped into the merged tree is exactly that mutant 3 of the 4 _287 tests fail (merged route_mask = 4) [T]
Reverse direction (survivor NULL, loser computed) ..._MergeRecomputesUnionWhenSurvivorUnknown_287 master 4 → head 6 [T]
Check other merged columns for NULL-means-unknown ..._MergeKeepsFillColumnsNullWhenBothUnknown_287 guard, green before and after — disclosed in the PR; correct, since the conclusion is "no code change needed" and MIN/MAX columns are NOT NULL while fill columns use COALESCE(survivor, loser) [T][A]
Say whether already-merged rows can be repaired PR body, "Repairing already-merged rows" analysis, no write added. I agree: a corrupted row holds a partial non-NULL mask indistinguishable from a complete one, so it is not targetable, and a blanket route_mask = NULL + rebackfill would shrink masks that live ingest legitimately grew (the backfill is a documented lower bound, the mask is monotonic). [A]

All four _287 tests pass on the merged tree; three are red on master and all three route-mask ones are red on round-1 head. [T]

7. Scope. git diff --name-only origin/master...5e95d93f = cmd/ingestor/hash_migrate.go, cmd/ingestor/route_mask_backfill.go, cmd/ingestor/hash_migrate_route_mask_287_test.go. cmd/server, public/ and the workflow files are byte-identical to master (0-line diff). Every write stays in the ingestor. 0 new map[string]interface{} on the added lines. The only behaviour change outside the merge rule is the extra change-log row of finding 4. [T]

Tests and mutants

Trees. git archive of head 5e95d93f and of the merged tree ac0f8932 = git merge-tree --write-tree origin/master 5e95d93f, with origin/master at 22589f05. Comparison trees: the merged tree with master's two source files ("master code"), and with round-1's ("round-1 code"). git ls-remote shows head 5e95d93f before and after the review. [T]

Run (merged tree unless noted) Result
cmd/ingestor go test ./... ok 114.1 s [T]
the four _287 tests 4 PASS [T]
the four _287 tests, master code 3 FAIL (merged route_mask = 4), fill guard PASS [T]
the four _287 tests, round-1 code 3 FAIL (merged route_mask = NULL), fill guard PASS [T]
cmd/server go test ./... run 1 FAIL: 4 TestHandleNodePaths_* tests with 503 {"error":"index loading"}; run 2 ok 51.8 s; the same 4 pass in isolation. cmd/server is byte-identical to master, so this is a pre-existing order/load-dependent test-isolation issue, not this PR. Not #256 and not #267; also not #301, which is a -race data race in a different test. [T][A]
sh test-all.sh 222 passed, 0 failed [T]
node test-frontend-helpers.js 709 passed, 0 failed [T]
bash scripts/check-xss-sinks.sh --diff origin/master no public/**/*.{js,html} changes to scan, exit 0 [T]

E2E against a local Go server on a freshened e2e-fixture.db prepared as in CI (the Kpa-clawbot#1486 seed SQL, corescope-migrate, then seeds 2073, 199 and 245), instrumented frontend, server stopped by port: [T]

E2E Result
test-issue-2073-recent-adverts-e2e.js (route_mask consumer) 10/10
test-issue-245-advert-intervals-e2e.js (route_mask consumer) 7/7
test-e2e-playwright.js 131 passed, 3 skipped, 1 failed: Version info lives on Perf dashboard, not in navbar (#navStats waitForFunction, 10 s). The stock fail-fast run stops there; the 131/135 count comes from a scratch copy with fail-fast disabled. public/ and cmd/server are byte-identical to master, so the binary and assets under test are master's — this cannot be caused by the PR, and CI's Playwright job is green on this head. [T][K]

The PR adds no E2E. Correct here: this is an ingestor-only one-time migration path that the server never runs during E2E, and the route-mask consumers read masks seeded directly by SQL. [A]

Mutants. Each applied to the merged tree and reverted after the run. "PR + existing" = -run 'ContentHash|RouteMask|FillableColumns|_287' without my probes; "full" = go test ./....

Mutant PR + existing Full ingestor suite Reviewer probes
MX1 recomputeMergedRouteMask reads only the winner's route_type survives survives (ok 122.8 s) killed by RX5 (2, want 6)
MX2 return mask, nil before the observation scan survives survives (ok 108.4 s) killed by RX4 (2, want 6)
MX3 observation scan reads the loser's rows, not the winner's survives survives (ok 106.1 s) killed by RX4 (2, want 6)
MX4 routeMaskBitFromHeader always returns 0 killed — but only by the pre-existing route_mask_backfill tests; no merge-path test notices — killed by RX4
MX5 survivor known / loser NULL ⇒ keep the survivor's mask killed: ..._MergeRecomputesUnionWhenLoserUnknown_287 (4) — —
MX6 survivor NULL / loser known ⇒ copy the loser's mask killed: ..._MergeRecomputesUnionWhenSurvivorUnknown_287 + ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 (4) — —
master's hash_migrate.go (the COALESCE(…, 0) mutant the issue asks for) killed: 3 of 4 _287 tests (4) — RX1 4, RX5 2

Reviewer probes (scratch only, never pushed):

Probe master round-1 head head
RX1 end state, dedup-dropped known bit 4 2 6
RX2 both NULL, both observations surviving 6 (via backfill) 6 6 (in the merge)
RX3 both NULL, no route_type, no observations NULL → 0 NULL → 0 known 0 in the merge; a live-ingest-shaped OR still grows it to 4
RX4 the only DIRECT evidence is a surviving observation header 6 6 6 — and 2 under MX2/MX3/MX4
RX5 the only DIRECT evidence is the loser's route_type 2 2 6 — and 2 under MX1

RX5 is a second case this PR improves over master beyond the reported repro. [T]

Guardrails

Not verified

  • The server-side consumer of the new change-log row was read, not exercised against a running server: RefreshRouteMaskChanges → mergeKnownRouteMask ORs the logged mask, so a 0 row only flips routeMaskKnown and a grown mask is applied without a restart. That matches the round-2 report's finding 3, by code reading only. [A]
  • How often the dedup-drop case occurs on real data. No staging or production access was used, and no API key. [A]
  • Long-run migration behaviour at production scale: my timing probe is 500 merges on one host, not a staging-class measurement. [T]
  • The cmd/server 503 index loading failures were reproduced once and then not again; I did not bisect them to a specific test ordering. [T]

dborup and others added 3 commits October 6, 2026 08:33
…ge-route-mask-null

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 3 (findings 1 and 3). Three guards for code shipped in
round 2, red under the reviewer's mutants:

- the only DIRECT evidence is a surviving observation header
  (kills MX2 no scan, MX3 scan the loser's rows, MX4 header bit 0);
- the only DIRECT evidence is the loser's route_type (kills MX1);
- both sides NULL, with and without evidence: the merge writes a known
  mask and logs exactly one route_mask_changes row (kills a both-NULL
  merge that stays NULL, and a merge that only logs for a known survivor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#287)

Review round 3 (finding 4): a survivor whose route_mask was NULL always
logs one route_mask_changes row, even for a known 0, so a pre-#89 DB logs
one row per both-NULL merge. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-pve-agent3 PR#295 runde 3 — head c0d1d51

Review feedback addressed (commits 4b89c5fd tests, c0d1d51a comment; merge of origin/master 22589f05 in 2acf715a)

Evidence tags: [T] = I ran it; [A] = code reading/analysis; [K] = taken from the PR, earlier reports or CI.

  1. Finding 1 (test gap): fixed. Two new tests in cmd/ingestor/hash_migrate_route_mask_287_test.go, built from the reviewer's RX4/RX5 shapes:

    • TestContentHashMigration_MergeRecomputesBitFromSurvivingObservationHeader_287: survivor route_mask NULL with route_type FLOOD, holding one FLOOD and one DIRECT observation. The loser is computed FLOOD, with route_type FLOOD and a FLOOD observation. The survivor's surviving DIRECT header is the only DIRECT evidence. The test asserts DIRECT|FLOOD right after the merge and again after the backfill.
    • TestContentHashMigration_MergeRecomputesBitFromLoserRouteType_287: the survivor is computed FLOOD. The loser is NULL with route_type DIRECT, and its one observation has no raw_hex. The loser's route_type is the only DIRECT evidence. The test asserts DIRECT|FLOOD after the merge and after the backfill.

    All four reviewer mutants now die in the full cmd/ingestor suite (table below). [T]

  2. Finding 2 (stale PR body): fixed with gh pr edit 295 --body-file -. The body now describes the shipped design: an inline recompute in the merge transaction, the five-term OR, why round 1's deferral to the backfill was dropped, the change-log row behaviour and the cost. It also has a 7-row test table with full names and a mutant table covering rounds 1, 2 and 3. The rendered body was checked for clean markdown (no literal \n), no closing keywords, and closingIssuesReferences = 0. [T] There is no mutant for this one, because it is a docs-only finding.

  3. Finding 3 (both-NULL unpinned): fixed. TestContentHashMigration_MergeBothRouteMasksUnknown_287 has two subtests:

    • with evidence: survivor NULL/FLOOD and loser NULL/DIRECT, both observations surviving. Expected: a known DIRECT|FLOOD (6).
    • without evidence: route_type NULL on both sides and no observations. Expected: a known 0.

    Each subtest asserts that the merge writes the known value, that exactly one route_mask_changes row carries it, and that the backfill leaves it unchanged. Mutant M5 (a both-NULL merge stays NULL and logs nothing, as on master) is killed. [T]

  4. Finding 4 (file comment): fixed in cmd/ingestor/hash_migrate.go, comment only. The header now says that a survivor whose mask was NULL always logs one row, even for a known 0, which means one row per both-NULL merge on a pre-Same advert seen on flood and zero-hop routes keeps the route of the first inserted observation #89 DB. The code logs on !oldMask.Valid || mergedMask != oldMask.Int64 inside ex.routeMaskChanges, so the sentence matches it. [A] The behaviour the comment describes is pinned by the finding-3 test. Mutant M6 (log only when the survivor's mask was already known) is killed. [T]

  5. Finding 5 (one IN (?, ?) query): skipped. The reviewer measured the cost as negligible (+16 µs per merge) [K]. Folding the four point reads into one would change the signature and control flow of recomputeMergedRouteMask, which is exactly the code the reviewer's mutants and this round's tests target. That is not a trivial change, and this round is scoped to no production behaviour change beyond the comment. [A]

The production diff this round is the comment only. git diff 5e95d93f..c0d1d51a outside the merge commit touches hash_migrate.go (+3/−1, comment lines) and the test file (+130). [T]

Red → green

Findings 1 and 3 pin code that already shipped in round 2, so the new tests are green on head. Their red state is shown against the mutants and against master's merge code, each applied to a throwaway copy of the merged tree. [T]

Tests

Run Result
cmd/ingestor go test ./... (head tree, TMPDIR on tmpfs) ok 105.9 s [T]
the seven _287 tests (incl. 2 subtests) 7 PASS [T]
cmd/server go test ./... ok 35.4 s [T]
sh test-all.sh run 1: 221 passed, 1 failed (test-channels-client-state-152.js, assertion R4-3 S2); run 2: 222 passed, 0 failed [T]
node test-frontend-helpers.js 709 passed, 0 failed [T]

The test-channels-client-state-152.js failure is an intermittent timing flake unrelated to this PR. In three isolated reruns, two passed and one failed on a different assertion (N1). The file and public/ are byte-identical to origin/master, and this PR touches only cmd/ingestor. It is neither #271 nor #301. [T][A]

E2E against a local Go server (port 13900) built from head, on a scratch copy of e2e-fixture.db prepared as in CI: freshen-fixture.sh, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL extracted from deploy.yml, corescope-migrate, then seeds 2073, 199 and 245. The frontend was instrumented with scripts/instrument-frontend.sh, and the server was stopped by port afterwards. [T]

E2E Result
test-issue-2073-recent-adverts-e2e.js (route_mask consumer) 10/10 [T]
test-issue-245-advert-intervals-e2e.js (route_mask consumer) 7/7 [T]
test-e2e-playwright.js 132/135 passed, 3 skipped, 0 failed [T]

No E2E is affected by the change. It is an ingestor-only, one-time migration/merge path that the server never runs during E2E. [A]

Mutants

Each mutant was applied to a copy of the merged tree and run against the full cmd/ingestor suite. Each copy was discarded afterwards.

Mutant Finding Full-suite result Killed by
MX1 read only the winner's route_type 1 FAIL ..._MergeRecomputesBitFromLoserRouteType_287 (2, want 6) [T]
MX2 return mask, nil before the observation scan 1 FAIL ..._MergeRecomputesBitFromSurvivingObservationHeader_287 (2, want 6) [T]
MX3 observation scan reads the loser's rows 1 FAIL ..._MergeRecomputesBitFromSurvivingObservationHeader_287 (2, want 6) [T]
MX4 routeMaskBitFromHeader always returns 0 1 FAIL ..._MergeRecomputesBitFromSurvivingObservationHeader_287 on the merge path, plus the existing TestBackfillTxRouteMask_* / TestStartRouteMaskBackfill_* tests [T]
M5 a both-NULL merge stays NULL, logs no row 3 FAIL ..._MergeBothRouteMasksUnknown_287 (both subtests: NULL, want known 6 / 0) [T]
M6 no change row when the survivor's mask was NULL 4 FAIL ..._MergeBothRouteMasksUnknown_287 (both subtests: 0 rows, want 1) [T]
master's hash_migrate.go + route_mask_backfill.go — FAIL 6 of 7 _287 tests (all but the fill-column guard) [T]
none (head) — ok — [T]

Guardrails

  • Commits 2acf715a (merge), 4b89c5fd and c0d1d51a: author and committer dborup <kontakt@meshview.dk>. Only explicit git add. Fast-forward push 5e95d93f..c0d1d51a, with no rebase, amend or force-push. [T]
  • The diff vs origin/master is still only cmd/ingestor/hash_migrate.go, cmd/ingestor/route_mask_backfill.go and the _287 test file. cmd/server, public/ and the workflows are untouched. There is no new map[string]interface{}. [T]
  • PR stays draft, with no closing keywords. No staging or production access was used, and no API key. [T]

CI — per job (run 37441403893, head c0d1d51a)

Job Result
✅ Go Build & Test pass (19m18s)
🎭 Playwright E2E Tests pass (26m01s)
🏗️ Build & Publish Docker Image pass (49s)
📦 Release Artifacts skipped (fork guard)
🚀 Deploy Staging / 📝 Publish Badges & Summary skipped (fork guard)

The run concluded success and headSha matches. Neither known flake (#271, #301) triggered, so no re-run was needed. [T][K]

@dborup

dborup commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Review — CS-Minimax PR#295 — head c0d1d51

Dom: APPROVE med nits

Independent round-3 re-review. All four round-2 findings are addressed and I reproduced each of
them myself: the four mutants that survived the full cmd/ingestor suite in round 2 now die, the
PR body matches the shipped design and the real test list, the both-NULL case is pinned, and the
file comment matches the code. The production delta since round 2 is the three-line comment and
nothing else. One residual test gap of the same family as round-2 finding 1 survives, plus two
docs nits.

Evidence tags: [T] = I ran it; [A] = code reading/analysis; [K] = taken from the PR,
the issue or CI.

Findings

# Severity Finding Evidence
1 Nit (test gap) The merged mask has five evidence terms, and the fifth — the header bits of the loser's observations, after they are re-parented onto the survivor — is still unpinned. My mutant N2 (move recomputeMergedRouteMask above the UPDATE OR IGNORE observations move, so the scan sees only the survivor's own rows) survives the full cmd/ingestor suite (ok 109.5 s). My probe RZ1 — survivor computed FLOOD with a FLOOD observation, loser route_mask NULL with route_type FLOOD but one surviving DIRECT observation, so the loser's observation header is the only DIRECT evidence — gives head 6, N2 2, master 2. The mutant is not worse than master, but it silently rolls the fix back to master's loss for that shape and no test notices. Round-2 finding 1 named four mutants and all four now die; this is the one remaining source. Fix: one more case shaped like RZ1. [T] mutant + probe tables below
2 Nit (docs) The PR title still describes the round-1 design: "keep route_mask NULL when merging an uncomputed side". The shipped merge makes the mask always non-NULL. Round-2 finding 2 brought the body in line, and the body is now accurate; the title is the remaining stale artefact and is the first thing a maintainer reads. [K] title vs hash_migrate.go:331-358
3 Nit (docs, trivial) The body's Cost paragraph says "The round-3 reviewer measured 500 both-NULL merges in one batch at 89 ms on master and 97 ms on this branch". Those numbers come from the round-2 review (of head 5e95d93f). I did not re-measure them. [K] PR body vs the round-2 review comment
4 — (agreement) Round-2 finding 5 (fold the four point reads into one WHERE id IN (?, ?)) stays skipped. I agree: the measured cost is negligible and the round is scoped to no production behaviour change beyond the comment. The loop is N = 2, not per-observation. [A] hash_migrate.go:401-419

No blocking finding. The shipped behaviour is correct everywhere I probed it, including the three
cases the fix improves over master beyond the reported repro (the dedup-dropped stored bit, the
survivor's own observation header, and the loser's route_type).

Answers to the review points

1. Round-2 finding 1 (test gap) — addressed. I re-ran MX1–MX4 against head myself; all four now
die in cmd/ingestor.
Each mutant was applied to its own throwaway copy of the merged tree and
run against the full suite (go test ./...), never with a git checkout -- revert. [T]

Mutant Full cmd/ingestor suite Killed by Value
MX1 for _, id := range [...]int64{winner} (only the winner's route_type) FAIL 123.4 s ..._MergeRecomputesBitFromLoserRouteType_287 2, want 6
MX2 return mask, nil before the observation scan FAIL 110.7 s ..._MergeRecomputesBitFromSurvivingObservationHeader_287 2, want 6
MX3 observation scan bound to loser (empty after the move) FAIL 112.1 s ..._MergeRecomputesBitFromSurvivingObservationHeader_287 2, want 6
MX4 routeMaskBitFromHeader always returns 0 FAIL 113.0 s ..._MergeRecomputesBitFromSurvivingObservationHeader_287 on the merge path, plus TestBackfillTxRouteMask_LowerBoundFromRouteAndObservations, TestBackfillTxRouteMask_ConcurrentLiveIngestLosesNoBits, TestStartRouteMaskBackfill_CompletesAndRerunsForNewNullRows 2, want 6

The two new tests are the right shapes and each isolates exactly one source: in
..._BitFromSurvivingObservationHeader_287 every other term is FLOOD, so only the survivor's own
DIRECT observation header can produce 6 (this is what kills MX2/MX3/MX4 and leaves MX1 alive);
in ..._BitFromLoserRouteType_287 the loser's observation has no raw_hex and both masks are
FLOOD, so only the loser's route_type can produce 6 (this is what kills MX1). [T][A] Finding 1
is now closed for four of the five evidence terms — the fifth is finding 1 above.

2. Round-2 finding 2 (stale PR body) — addressed. The body now describes the inline recompute,
the five-term OR, why round 1's deferral to the backfill was dropped, the change-row behaviour and
the cost. I diffed the body's test list against the test file mechanically: 7 names in the body,
7 in the file, zero on either side only
. None of the round-1 phrases the round-2 review quoted
("NULL whenever either side is NULL", "for no benefit", "asserts the merged mask is NULL before
the backfill") appear any more. closingIssuesReferences = 0 and the body carries no closing
keyword. [T] The title is finding 2 above.

3. Round-2 finding 3 (both-NULL unpinned) — addressed.
..._MergeBothRouteMasksUnknown_287 has with evidence (known 6) and without evidence
(known 0), and each subtest asserts three things: the merge writes the known value, exactly
one
route_mask_changes row carries it, and the backfill leaves it unchanged. My mutant run:
M5 (a both-NULL merge stays NULL and logs nothing, master's behaviour) is killed by both
subtests; so is M6. [T] The safety argument behind writing a known 0 holds: live ingest's
grow statement is UPDATE transmissions SET route_mask = route_mask | ? WHERE id = ? AND route_mask IS NOT NULL AND (route_mask & ?) = 0 (cmd/ingestor/db.go:887-888), so it grows a
known 0 and would not touch a NULL. [A]

4. Round-2 finding 4 (the change-log row comment) — addressed, and the comment matches the
code.
The header now reads "logged like any grown mask, except that a survivor whose mask was
NULL always logs one row, even for a known 0 (one per both-NULL merge on a pre-#89 DB)". The code
is if !oldMask.Valid || mergedMask != oldMask.Int64 inside if ex.routeMaskChanges — when
oldMask is NULL the row is logged unconditionally, exactly as written. [A] M6 (if oldMask.Valid && mergedMask != oldMask.Int64) is killed by both subtests of the both-NULL test, so
the sentence is pinned, not just documented. [T] The server consumer handles a logged 0
correctly: mergeKnownRouteMask (cmd/server/route_mask.go:288-292) merges
sql.NullInt64{Int64: 0, Valid: true} and returns !known, so a 0 row flips routeMaskKnown
rather than being dropped by a truthiness test. [A]

5. No production behaviour change since round 2 beyond the comment — confirmed.
git diff 5e95d93f c0d1d51a -- cmd/ingestor/hash_migrate.go cmd/ingestor/route_mask_backfill.go
is one hunk, +3/−1, comment lines only. The full 5e95d93f..c0d1d51a diff also shows
cmd/server/hash_migrate.go, cmd/server/hash_migrate_distance_288_test.go,
cmd/ingestor/client_rx_minimised_test.go and docs/client-rx-coverage.md; every one of those is
in git diff --name-only 5e95d93f 2acf715a, i.e. it arrived with the master merge and none of it
is this PR's. [T]

6. Requirements and tests — each #287 criterion is red before and green after. I dropped
master's hash_migrate.go and route_mask_backfill.go into a copy of the merged tree (the
COALESCE(…, 0) mutant the issue asks for) and ran the full suite: 6 of the 7 _287 tests
fail, the fill-column guard passes
, with these values. [T]

#287 criterion Test master → head
Survivor computed DIRECT + loser NULL (heard FLOOD) → merge → backfill = DIRECT|FLOOD (the issue's repro) ..._MergeRecomputesUnionWhenLoserUnknown_287 4 → 6, unchanged by the backfill
Either side NULL must not silently become a partial non-NULL value (keep NULL or recompute in the same transaction) same test + ..._MergeRecomputesUnionWhenSurvivorUnknown_287 4 → 6 both directions
The mutant that restores COALESCE(…, 0) must fail master's own merge code as the mutant 6 of 7 _287 tests FAIL
Check the other merged columns for the same NULL-means-unknown pattern ..._MergeKeepsFillColumnsNullWhenBothUnknown_287 guard, green before and after — disclosed in the body; correct, since MIN/MAX columns are NOT NULL and the fill columns use COALESCE(survivor, loser), which never fabricates a sentinel
Say whether already-merged rows can be repaired body, "Repairing already-merged rows" analysis only, no write added. I agree with it: a corrupted row holds a partial non-NULL mask indistinguishable from a complete one, so it is not targetable, and a blanket route_mask = NULL + re-backfill would shrink masks live ingest legitimately grew (the backfill is a documented lower bound and the mask is monotonic)

The three round-3 tests guard behaviour that shipped in round 2, so they are green on head; the
body discloses that and shows their red state through the mutants. That is the honest framing and
it matches what I measured: against master's merge code tests 5, 6 and 7 fail too (2, 2,
NULL/NULL). [T]

7. Not too broad. The merged mask is a pure OR of five terms, every one of them evidence of a
frame actually received for this content hash. packetpath.RouteMaskBit returns 0 outside route
types 0..3, RouteTypeFromRawHex returns ok=false for a missing or non-hex prefix, and | is
idempotent, so no bit can appear that no side and no observation supports, and
mergedMask ⊇ oldMask, mergedMask ⊇ loserMask whenever those are known — a merge never shrinks
a stored mask. The both-known branch is a plain | with no COALESCE, so the original bug cannot
return through it. There are no triggers on transmissions, so the computed mergedMask the
UPDATE binds is exactly what the row holds afterwards; dropping master's post-UPDATE re-read
in favour of the computed value is therefore equivalent. [A] The routeMaskBitFromHeader
extraction is behaviour-neutral, and MX4 shows it is genuinely the live code path for the backfill
as well. [T][A]

Tests and mutants

Trees. git archive of head c0d1d51a and of the merged tree
3327c61ed28f4366d8e62d1b136e0651d671b3ac = git merge-tree --write-tree origin/master c0d1d51a
with origin/master at 30c7de46, both extracted into scratch. git ls-remote showed head
c0d1d51a before and after the review. [T]

Run (merged tree) Result
cmd/ingestor go test ./... ok 108.7 s [T]
cmd/ingestor go vet ./... / go build ./... clean [T]
the seven _287 tests (8 with subtests) 7 PASS / 8 PASS [T]
the seven _287 tests, master's two production files 6 FAIL (4,4,4,2,2,NULL×2), fill guard PASS [T]
cmd/server go test ./... ok 53.2 s [T]
sh test-all.sh 224 passed, 0 failed (224 files), first run, no re-run needed [T]
node test-frontend-helpers.js 709 passed, 0 failed [T]
bash scripts/check-xss-sinks.sh --diff origin/master no public/**/*.{js,html} changes to scan, exit 0 [T]

E2E against a local Go server built from the merged tree on port 13700, on a scratch copy of
e2e-fixture.db prepared as in CI: tools/freshen-fixture.sh, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL extracted
from the workflow, corescope-migrate, then seeds 2073, 199 and 245; frontend instrumented with
scripts/instrument-frontend.sh; server stopped by looking up the listener on the port, not $!. [T]

E2E Result
test-issue-2073-recent-adverts-e2e.js (route_mask consumer) 10/10 [T]
test-issue-245-advert-intervals-e2e.js (route_mask consumer) 7/7 [T]
test-issue-199-inactive-observer-e2e.js 3/3 [T]
test-e2e-playwright.js 131 passed, 3 skipped, 1 failed: Version info lives on Perf dashboard, not in navbar (#navStats waitForFunction, 10 s). The stock fail-fast run stops there; the 131/135 count comes from a scratch copy with fail-fast disabled. [T]

That one failure is a local timing flake, not this PR: public/ and cmd/server are byte-identical
to master in the merged tree (git diff --name-only origin/master <merged tree> -- public/ cmd/server/ is empty), so the assets and binary under test are master's; I replicated the test
standalone and it passed 3/3 in ~430 ms each; and a direct probe showed #navStats does fill in
("500 pkts · 204 nodes · 31 obs") with no page errors, just later than 10 s under the full suite's
load on this host. CI's Playwright job is green on this head. It is neither #256 nor #267. [T][A][K]
The PR adds no E2E, which is right: this is an ingestor-only one-time migration path the server
never runs during E2E, and the route-mask consumers read masks seeded directly by SQL. [A]

Mutants. Each applied to its own throwaway copy and run against the full cmd/ingestor
suite unless noted; the copies were discarded, never reverted in place.

Mutant Mine? Full suite Killed by
MX1 only the winner's route_type round-2 reviewer's FAIL 123.4 s ..._BitFromLoserRouteType_287 [T]
MX2 no observation scan round-2 reviewer's FAIL 110.7 s ..._BitFromSurvivingObservationHeader_287 [T]
MX3 scan the loser's observations round-2 reviewer's FAIL 112.1 s ..._BitFromSurvivingObservationHeader_287 [T]
MX4 routeMaskBitFromHeader always 0 round-2 reviewer's FAIL 113.0 s ..._BitFromSurvivingObservationHeader_287 + 3 backfill tests [T]
N1 substr(raw_hex, 1, 1) in the merge recompute (1 hex char instead of 2) mine FAIL 118.4 s ..._BitFromSurvivingObservationHeader_287 (2, want 6) [T]
N2 recompute moved above the observation re-parenting mine ok 109.5 s — survives nothing; killed only by my probe RZ1 (2, want 6) → finding 1 [T]
N3 drop if loserMask.Valid { m |= loserMask.Int64 } mine FAIL 109.7 s ..._MergeKeepsStoredBitsOfDedupDroppedObservation_287 (2, want 6) [T]
M5 a both-NULL merge stays NULL and logs no row author's, re-run FAIL (targeted ContentHash|RouteMask|_287) ..._MergeBothRouteMasksUnknown_287, both subtests [T]
M6 no change row when the survivor's mask was NULL author's, re-run FAIL (same targeted set) ..._MergeBothRouteMasksUnknown_287, both subtests [T]
master's hash_migrate.go + route_mask_backfill.go the issue's own mutant FAIL 110.2 s 6 of 7 _287 tests [T]
none (merged tree) — ok 108.7 s — [T]

Reviewer probe RZ1 (scratch only, never pushed): survivor computed FLOOD / route_type FLOOD /
one FLOOD observation; loser route_mask NULL / route_type FLOOD / one DIRECT observation on
a distinct observer and path, so it survives the move and its header is the only DIRECT evidence.
Asserts 2 surviving observations and DIRECT|FLOOD.

Tree RZ1 merged route_mask
head / merged tree 6 (PASS)
N2 mutant 2 (FAIL)
master's merge code 2 (FAIL)

So RZ1 is a fourth case this PR improves over master beyond the reported repro, and the one the
suite does not guard. [T]

Guardrails

  • git diff --name-only origin/master...c0d1d51a = cmd/ingestor/hash_migrate.go,
    cmd/ingestor/route_mask_backfill.go, cmd/ingestor/hash_migrate_route_mask_287_test.go. The
    merged tree against origin/master has the same three files and no conflict. cmd/server,
    public/ and the workflow files are byte-identical to master (0-line diff), so every write stays
    in the ingestor and cmd/server remains read-only. [T]
  • Commits 2acf715a (merge), 4b89c5fd, c0d1d51a and the earlier 635297512, 5e95d93f,
    a558c8c6: author and committer dborup <kontakt@meshview.dk> on all six. Head unchanged on
    the remote across the review. [T]
  • 0 new map[string]interface{} on the added lines (the merge's []interface{} args slice is
    pre-existing and is a slice, not a map). [T]
  • No hardcoded colours: the only #-prefixed tokens on added lines are #287 issue references,
    and there is no frontend or stylesheet change at all. [T]
  • Fork guards: 9 occurrences of the guarded repository in deploy.yml, 1 in
    release-fast-path.yml; no workflow file in the diff. [T]
  • No closing keyword in the PR body or in any of the six commit messages;
    closingIssuesReferences = 0. PR stays draft. [T]
  • No staging or production access was used, and no server API key. No push, merge, ready, PR edit
    or issue creation. Other agents' worktrees, processes and ports were left alone; port 13700 was
    checked free before use and released afterwards. [T]

CI — per job (run 37441403893, head c0d1d51a)

I pulled the jobs myself rather than trusting the run conclusion. headSha matches the head under
review. [T]

Job Result
✅ Go Build & Test success (19m18s)
🎭 Playwright E2E Tests success (26m01s)
🏗️ Build & Publish Docker Image success (49s)
📦 Release Artifacts skipped (fork guard)
🚀 Deploy Staging skipped (fork guard)
📝 Publish Badges & Summary skipped (fork guard)

Run conclusion success, and it is the only run on this head. Neither known flake (#256 Hash Stats
sort, #267 backfill write-hold) triggered, so no re-run was needed. [T][K]

Not verified

  • The performance claim in the body (89 ms vs 97 ms for 500 both-NULL merges, ≈ +16 µs per merge).
    I did not re-measure it; I only checked the query shape by reading, which matches the body's
    description: the both-known path is query-neutral and the NULL path adds 2 point SELECT route_type plus one idx_observations_transmission_id-driven scan, with no per-observation
    query. [A][K]
  • The server-side consumer of the new change row was read, not exercised against a running server:
    RefreshRouteMaskChanges → mergeKnownRouteMask ORs the logged mask and a 0 row flips
    routeMaskKnown. Code reading only. [A]
  • My cmd/server, sh test-all.sh and node test-frontend-helpers.js runs were against the merge
    with origin/master at 30c7de46. origin/master advanced to 2820d18d during the review; I
    re-merged against it and the result is still conflict-free with the same three files, and none of
    the new master commits touch cmd/ingestor or internal/, so the ingestor results carry over
    unchanged. The newer master does touch cmd/server, public/ and two root node tests, which I
    did not re-run. [T]
  • How often the dedup-drop case and the RZ1 shape actually occur on real data. No staging or
    production access was used. [A]
  • Long-run migration behaviour at production scale. [A]

@dborup dborup changed the title fix(ingestor): keep route_mask NULL when merging an uncomputed side (#287) fix(ingestor): recompute route_mask inline when merging an uncomputed side (#287) Oct 6, 2026
@dborup
dborup marked this pull request as ready for review October 6, 2026 13:25
@dborup
dborup merged commit de6e83b into master Oct 6, 2026
6 checks 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.

3 participants