Skip to content

fix: refuse a layout override whose manifest excludes the live topology - #3413

Merged
morozsm merged 5 commits into
mainfrom
codex/layout-topology-gate
Sep 16, 2026
Merged

morozsm merged 5 commits into
mainfrom
codex/layout-topology-gate

Conversation

@morozsm

@morozsm morozsm commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

The defect

LayoutManifest.compatibleTopologies was validated by validateLayoutManifest
and documented as a restriction on which radios a layout's skin may mount, but
no runtime path read it. resolveSkinId returned the preferred skin id
straight from ctx.layoutPreference, so ?layout=flagship-probe mounted the
geometry probe on an IC-7300 — live class 1/ab — although the probe's
manifest declares only 2/ab_shared and 2/main_sub.

LayoutManifest.fallbackLayoutId was in the same state: declared by six
shipped manifests, validated, and read by no runtime path.

The gate

admitsLiveTopology(id, capabilities) reads the manifest registered under the
skin id and compares its declared classes against ${structuralCount}/${scheme}
derived from ctx.capabilities — the same string
lib/runtime/adapters/radio-view-model-adapter.ts composes for its
topologyId, over the same TOPOLOGY_CLASSES vocabulary a manifest declares
against.

Two shapes are admitted without deriving anything: an id with no registered
manifest, and a manifest that declares every class in TOPOLOGY_CLASSES.
Neither can exclude a radio, so an unrestricted preference still resolves
before capabilities arrive. A manifest that does exclude a class must see its
own: an underivable topology (no capabilities yet, or a vfoScheme/receivers
pair derivePresentationCapabilities reports as invalid-topology) is
refused.

The fallback chain

A refused preference no longer drops straight to the default. resolveWithFallback
walks fallbackLayoutId and takes the first hop that is both a built-in skin
and admitted; a hop that is unregistered, registered but not loadable, or
itself refused continues down that hop's own chain. A visited set bounds the
walk: a chain naming an id already seen stops there. The endpoint when the walk
finds nothing is desktop-v2.

Where each gated preference lands on a radio its manifest excludes:

preference walk mounted
dual-receiver-cockpit sdr-test sdr-test
flagship-probe sdr-test sdr-test
peer-split lcd-cockpit lcd-cockpit
unified-instrument peer-split (refused) → lcd-cockpit lcd-cockpit
panadapter-first peer-split (refused) → lcd-cockpit lcd-cockpit

sdr-test and lcd-cockpit each declare all four topology classes, which is
why the same table holds for 1/ab, for no capabilities at all, and for an
invalid topology. A test asserts that property of both endpoints rather than
assuming it.

Gated skins and what the user sees

The gated ids are the five whose registered manifest leaves a class out:
dual-receiver-cockpit, flagship-probe, panadapter-first, peer-split,
unified-instrument — read off the shipped manifests by the test, not
hand-listed. Three of them (peer-split, unified-instrument,
panadapter-first) are options in components-v2/layout/StatusBar.svelte's
skin picker; the other two are reachable only through ?layout=<id>.

On a single-receiver radio, picking one of those three now renders the
fallback instead. The persisted preference is not touched — resolveSkinId is
pure and writes nothing — so the picker still shows the chosen value while a
different skin is on screen, and the only signal is one console.warn naming
the layout actually mounted. Surfacing the refusal in the picker itself is
T209.

The e2e fixture change

frontend/tests/e2e/i18n/desktop-geometry.spec.ts's two T185 cases booted the
probe on fixture()'s default caps (IC-7300, receivers: 1, vfoScheme: 'ab'
→ class 1/ab), which the gate refuses, so
[data-testid="flagship-geometry-probe"] never appeared and the i18n visual
smoke step failed on the previous head (2 failed / 87 passed). Both now pass
'topology-2-main-sub' as boot()'s 7th parameter.

No assertion in those two cases carries a geometry literal, and both already
pin the rail column they measure (200px narrow, 400px wide) with their own
addStyleTag, so nothing had to be re-tuned. Measured on the two-receiver
deck, from the cases' own attachments: key.width 131.12, unkey.width
130.83, columnGap 8 → pair 269.95 in both cases; actions.width 200.00
(narrow, buttons stacked) and 400.00 (wide, one line).

Tests and mutations

Gates run locally at the pre-merge head 277b399d, before origin/main was merged into the branch (kept as the record of that head; the current head's evidence is under "CI at this head"):

  • npx vitest run src/skins/__tests__/ src/presentation/ src/__tests__/design-language-activation.component.test.ts — 66 files, 1390 tests, 0 failed
  • npm run checkCOMPLETED 4844 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
  • npm run lint — no output (clean)
  • npm run lint:boundaries — no output (clean)
  • npm run build then npx playwright test -c ./playwright.i18n.config.ts tests/e2e/i18n/desktop-geometry.spec.ts — 42 passed (45.2s)

Mutations, each applied to the restored tree and reverted afterwards:

  1. Fallback ignored. Inserted reportRefusal(id, capabilities, DEFAULT_SKIN_ID); return DEFAULT_SKIN_ID; immediately after the admit check in resolveWithFallback, so a refusal never walks the chain. src/skins/__tests__/registry.test.ts: 12 failed / 81 passed — every "routes the refused X preference to its declared fallback" case, every "never mounts X while the live topology is underivable" case, "names the layout the walk actually mounted", and the once-per-pair report case.
  2. Chain unbounded. Replaced while (candidate !== null && !visited.has(candidate)) { visited.add(candidate); with while (candidate !== null) {. The run never produced a result: the cycle registered by "stops at the default when the chain names an id already visited" spins the resolver forever, and the vitest worker had to be killed after >600s with no test output. That is the failure mode the bound exists to prevent.
  3. Control. Renamed the walker's local candidate to hop (9 occurrences inside resolveWithFallback), a behaviour-preserving edit: 93 passed / 0 failed, unchanged from the unmutated file.
  4. E2E fixture reverted. Put undefined back as boot()'s topology argument in both T185 cases: both fail at boot's
    await expect(page.locator('[data-testid="flagship-geometry-probe"]').first()).toBeVisible() — the exact CI red this PR fixes.

Class sweep for the fixture defect: qaLayout / ?layout=<gated id> appears in
tests/e2e/i18n/desktop-geometry.spec.ts only. presentation-switch-resources. component.test.ts and presentation-switch-tx.component.test.ts mention
?layout=flagship-probe in comments but select skins through their own
widthToSkin table, not the resolver; the visual specs mount fixtures
directly. No other site reaches a gated skin through resolveSkinId.

Review fixes carried in this head

  • design-language-activation.component.test.ts: the stub caps said
    vfoScheme: 'main_sub' under a comment calling 2/main_sub "the FTX-1's
    MAIN/SUB pair". rigs/ftx1.toml declares scheme = "ab_shared" under
    [vfo]; the stub and the sentence now say ab_shared.
  • skins/registry.ts: the reportedRefusals docstring claimed it was "never
    read by admitsLiveTopology" while that function read it for the throttle.
    Reporting now lives in its own reportRefusal, and the clause is deleted.
  • skins/registry.ts: the resolveSkinId bullet claiming "the two that do not
    carry the term" is replaced — it did not count the isMobile branch.
  • lib/stores/qa-cockpit-override.ts: one sentence recording the topology
    refusal as a second way ?layout= no-ops, tied to
    skins/__tests__/registry.test.ts's "layout-manifest topology gate".

Size

Census at 6793ca0940d17da0b3e80e0a838d1104de87ca8e, the head after origin/main (6d7e712f) was merged into the branch and two untrue comment claims were deleted: 6 files, 388+/29- = 417 changed lines, re-measured with git diff --numstat origin/main...HEAD.
At the 6-file soft threshold, under the 600-line one. One unit of work: the
gate and the fallback it falls to are the same decision in resolveSkinId, the
two test files pin that decision, and the e2e fixture and the
qa-cockpit-override.ts sentence are the two places the gate's own effect
shows up elsewhere. Splitting would land a gate whose e2e is red.

CI at this head

At the current head 6793ca09: Agent Review Gate — pass, bound to this head; quick — run 35039192901 (the comment-only delta's own run). The pre-delta head 32df3bfa had quick pass in 5m45s, run 35038270578. The paragraph below records the earlier run 34362092289 (4m59s) at the pre-merge head, whose selection reasoning still applies. Its own selection step reports
Core checks selected: false / Frontend checks selected: true: this PR
touches only frontend/, so quick.yml's core filter skipped the pytest and
ruff block and the frontend block ran. Frontend vitest: 470 files, 11651 tests,
all passed. Frontend i18n visual smoke: 89 passed (previous head: 2 failed / 87
passed). visual — pass. Agent Review Gate — red pending the independent
review directive.

internal-identifier self-check — empty

Linear: MOR-2425

🤖 Generated with Claude Code

`LayoutManifest.compatibleTopologies` was validated by
`validateLayoutManifest` and documented as a restriction, but no runtime
path read it: `resolveSkinId` returned the preferred skin id straight
from `ctx.layoutPreference`, so `?layout=flagship-probe` mounted the
probe on an IC-7300 (`1/ab`) although its manifest declares only
`2/ab_shared` and `2/main_sub`.

Each forced-preference branch in `resolveSkinId` now also requires
`admitsLiveTopology`, which reads the manifest registered under the skin
id and compares its declared classes against `${structuralCount}/
${scheme}` derived from `ctx.capabilities` — the same string
`radio-view-model-adapter.ts` composes for `topologyId`. A refused
preference falls through to the default, and the refusal is reported
once per (layout, live class) via `console.warn`, the channel
`lib/stores/qa-cockpit-override.ts` already uses to explain its own
no-op override.

An id with no registered manifest, and a manifest that declares every
class in `TOPOLOGY_CLASSES`, are admitted without deriving anything, so
an unrestricted preference still resolves before capabilities arrive.
The manifests that can refuse today are dual-receiver-cockpit,
flagship-probe, peer-split, unified-instrument and panadapter-first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@morozsm
morozsm marked this pull request as ready for review September 9, 2026 13:24
morozsm and others added 3 commits September 9, 2026 09:49
…be e2e on a two-receiver fixture

`resolveSkinId` refused a layout preference whose manifest excludes the live
receiver topology and then returned `desktop-v2`, ignoring the manifest's own
`fallbackLayoutId`: six shipped manifests declare that field and no runtime
path read it. A refusal now walks the chain and takes the first hop that is a
built-in skin admitting the live topology — dual-receiver-cockpit and
flagship-probe to sdr-test, peer-split to lcd-cockpit, unified-instrument and
panadapter-first through the refused peer-split to lcd-cockpit — ending at
`desktop-v2` when the chain names an id already visited or names no registered
layout. The one `console.warn` per refused (preference, live class) pair now
names the skin actually mounted.

`tests/e2e/i18n/desktop-geometry.spec.ts`'s two T185 cases booted the probe on
the default single-receiver fixture (class 1/ab), which the gate refuses, so
`[data-testid="flagship-geometry-probe"]` never appeared and the i18n visual
smoke step failed. Both now boot the `topology-2-main-sub` catalog fixture;
each already pins the rail column it measures, so the pair measurement is
unchanged in shape.

Review fixes: the stub caps in `design-language-activation.component.test.ts`
name `ab_shared`, the scheme `rigs/ftx1.toml` declares under `[vfo]`; the
`reportedRefusals` docstring no longer claims `admitsLiveTopology` never reads
it; `qa-cockpit-override.ts` records the topology refusal as a second way
`?layout=` no-ops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@morozsm

morozsm commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Agent Review: PASS 32df3bf

Head confirmed: git rev-parse HEAD = 32df3bfa7b91af132fdc2ccbc06e2672451602ba, equal to gh pr view 3413 --json headRefOid ("headRefOid":"32df3bfa7b91af132fdc2ccbc06e2672451602ba", state OPEN, MERGEABLE). Detached worktree, git status --short empty at start.

1. The merge brought only the merge

$ git diff origin/main...HEAD --numstat
10	1	frontend/src/__tests__/design-language-activation.component.test.ts
6	0	frontend/src/lib/stores/qa-cockpit-override.ts
12	2	frontend/src/presentation/workspace/__tests__/selection-adoption.test.ts
230	14	frontend/src/skins/__tests__/registry.test.ts
125	10	frontend/src/skins/registry.ts
6	2	frontend/tests/e2e/i18n/desktop-geometry.spec.ts

$ git merge-base origin/main HEAD   # == origin/main (git rev-parse origin/main)
6d7e712f81e98a53db094e2487d2215501f60fb0

$ git log --oneline origin/main..HEAD
32df3bfa Merge remote-tracking branch 'origin/main' into codex/layout-topology-gate
277b399d fix: route a refused layout to its declared fallback and boot the probe e2e on a two-receiver fixture
c1350fb8 Merge remote-tracking branch 'origin/main' into codex/layout-topology-gate
7377b88d fix: refuse a layout override whose manifest excludes the live topology

Merge-base equals origin/main, and the three-dot diff is exactly the six owned frontend/ files, so every other path in HEAD is byte-identical to origin/main. Nothing from main was reverted; the merge itself is the only new commit (parents 277b399d / 6d7e712f).

2. The "no rework needed" conclusion holds

boot() signature is byte-identical at both refs:

$ git show origin/main:frontend/tests/e2e/i18n/desktop-geometry.spec.ts | grep -n "async function boot"
104:async function boot(page: Page, layout: string, width: number, known: boolean, language = 'studioline', productionUnknown = false, topology?: TopologyId, options: BootOptions = {}) {
$ git show HEAD:frontend/tests/e2e/i18n/desktop-geometry.spec.ts | grep -n "async function boot"
104:async function boot(page: Page, layout: string, width: number, known: boolean, language = 'studioline', productionUnknown = false, topology?: TopologyId, options: BootOptions = {}) {

BootOptions at origin/maintopology is NOT a member (it is the 7th positional parameter):

interface BootOptions {
  height?: number; theme?: 'nord' | 'github-light'; locale?: 'en-US' | 'ru-RU';
  extraCapabilities?: string[]; absoluteVfoPair?: boolean;
  txState?: 'rx' | 'tx'; txTargetSlot?: 'A' | 'B' | 'unknown';
  qaLayout?: 'flagship-probe';
}

The PR's hunk changes undefined'topology-2-main-sub' for boot()'s 7th positional argument in both T185 cases (desktop-geometry.spec.ts:918,942):

-    await boot(page, 'standard', 1440, true, 'studioline', false, undefined,
+    await boot(page, 'standard', 1440, true, 'studioline', false, 'topology-2-main-sub',
       { qaLayout: 'flagship-probe' });

Main did touch this spec (the merge added absoluteVfoPair/txState/txTargetSlot to BootOptions and changed catalogFixture/fixture), but it did not move topology into BootOptions nor change its position. The hunk applies to current main as written. Conclusion confirmed.

3. The defect is still live on main

No topology admission exists on origin/main, whole repo:

$ git grep -n "admitsLiveTopology\|resolveWithFallback" origin/main
(no output)

origin/main's resolveSkinId returns the preference directly, ignoring manifests:

$ git show origin/main:frontend/src/skins/registry.ts | sed -n '118,137p'
  if (ctx.layoutPreference === 'dual-receiver-cockpit') return 'dual-receiver-cockpit';
  if (ctx.layoutPreference === 'flagship-probe') return 'flagship-probe';
  ...
  if (layoutPreference === 'peer-split') return 'peer-split';
  ...

So ?layout=flagship-probe on an IC-7300 (receivers:1, vfoScheme:'ab' → class 1/ab) mounted the probe although flagship-probe declares only ['2/ab_shared','2/main_sub'] (declarations.ts:183). Live.

4. The fallback chain is bounded and ends at desktop-v2

registry.ts:186-200:

  const visited = new Set<string>([id]);
  let mounted: SkinId = DEFAULT_SKIN_ID;                 // 'desktop-v2'
  let candidate = getLayout(id)?.fallbackLayoutId ?? null;
  while (candidate !== null && !visited.has(candidate)) {
    visited.add(candidate);
    if (BUILT_IN_SKIN_IDS.has(candidate) && admitsLiveTopology(candidate, capabilities)) {
      mounted = candidate as SkinId;
      break;
    }
    candidate = getLayout(candidate)?.fallbackLayoutId ?? null;
  }

Every iteration adds candidate to visited before advancing, and the guard rejects any already-seen id, so a manifest cycle exits after finitely many hops. mounted starts at DEFAULT_SKIN_ID = 'desktop-v2' and can only be overwritten by an admitted built-in; desktop-v2 itself declares all four classes (desktop-declarations.ts:161) with fallbackLayoutId: null, so both the admit and the run-out paths land on desktop-v2. The shipped flagship-probe → sdr-test chain is covered (sdr-test declares all four, fallbackLayoutId: null), and the cycle is already pinned by a test, which passes:

$ npm --prefix frontend run test -- src/skins/__tests__/registry.test.ts -t "layout fallback chain terminates"
 Test Files  1 passed (1)
      Tests  2 passed | 91 skipped (93)

The cycle case (dual-sdr-face → fallback-hop-one → fallback-hop-two → fallback-hop-one) expects 'desktop-v2' (registry.test.ts:637-642). No extra double needed.

5. Mutation: admitsLiveTopology returns true unconditionally

Inserted return true; as the first statement of admitsLiveTopology and reran:

$ npm --prefix frontend run test -- src/skins src/__tests__ src/presentation
 FAIL  src/skins/__tests__/registry.test.ts > layout-manifest topology gate > never mounts flagship-probe while the live topology is underivable
AssertionError: expected 'flagship-probe' to be 'sdr-test'
 FAIL  src/skins/__tests__/registry.test.ts > layout fallback chain terminates > stops at the default when the chain names an id already visited
AssertionError: expected 'dual-sdr-face' to be 'desktop-v2'
 FAIL  ... (14 failures total)
 Test Files  1 failed | 100 passed (101)
      Tests  14 failed | 2208 passed (2222)

Restored with git checkout -- frontend/src/skins/registry.ts:

$ git status --short
(empty)

The gate is load-bearing: removing it fails 14 assertions across the gate and the chain-bound suites.

6. Checks

$ npm --prefix frontend run test -- src/skins src/__tests__ src/presentation
 Test Files  101 passed (101)
      Tests  2222 passed (2222)

$ npm --prefix frontend run check
COMPLETED 4856 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS

$ npm --prefix frontend run lint
(no output)

npm ci was run in this worktree first (node_modules was absent). The e2e suite was NOT run — it needs a server; no claim is made about it here.

7. Prose

All added comments check out except one. Verified true: the e2e T185 block ("the probe's manifest declares only the two dual-receiver topology classes" → declarations.ts:183; 'topology-2-main-sub' boots catalogFixture); the qa-cockpit-override.ts sentence (dual-receiver-cockpit and flagship-probe both declare fallbackLayoutId: 'sdr-test', which admits all classes); the design-language-activation stub (peer-split declares ['2/ab_shared','2/main_sub'] at segmentline-declarations.ts:78; rigs/ftx1.toml:613 is scheme = "ab_shared"); liveTopologyClass's citation of radio-view-model-adapter.ts (topologyId composes `${topology.structuralCount}/${topology.scheme}` at line 1867); the side-effect-import rationale (App.svelte:32 imports the same barrel; an unregistered id is admitted); BUILT_IN_SKIN_IDS (commitExternalPresentationBatch writes into SKIN_LOADERS at registry.ts:392); "validated but read by no runtime path before this" for both fields.

Inaccurateregistry.ts:210-212, the resolveSkinId bullet:

 *   chain, ending at `DEFAULT_SKIN_ID`. The 'standard' branch returns
 *   'desktop-v2' directly — the walk's own endpoint, so routing it through
 *   the walker could only report a refusal and return the same id

admitsLiveTopology('desktop-v2', caps) returns true for any capabilities, because it short-circuits on TOPOLOGY_CLASSES.every(declared.includes) and desktop-v2 declares all four (desktop-declarations.ts:161). So routing 'standard' through resolveWithFallback would return 'desktop-v2' and emit no refusal warning. The clause "could only report a refusal and return the same id" is not reachable at this head — no test fails on it, which is exactly the prose-standard case. Refuse no verdict on it; see REQUIRED BEFORE MERGE.

8. Guardrails

$ git diff origin/main...HEAD --shortstat
 6 files changed, 389 insertions(+), 29 deletions(-)

6 files · 418 changed lines. At the 6-file soft threshold, under the 1000/600 ceilings. The PR body's "Size" section does say why it is one unit of work.

9. CI as found (not waited on)

$ gh pr checks 3413 --required
Agent Review Gate	fail	0	https://github.com/rigplane/rigplane-core/actions/runs/35038270452
quick	pending	0	https://github.com/rigplane/rigplane-core/actions/runs/35038270578/job/104612216102

Agent Review Gate red is expected pending this directive; quick was still queued/pending when observed. The verdict above is on the code alone.


REQUIRED BEFORE MERGE

  1. PR body is stale for this head. The body names 277b399da301942b166345caea726b218ac4756d, census merge base 695e56d878eaeccb1ea5df3406064927002c4c5d, and CI run 34362092289; the reviewed head is 32df3bfa with merge base 6d7e712f. gh api repos/rigplane/rigplane-core/actions/runs/34362092289"head_sha":"277b399da301942b166345caea726b218ac4756d", i.e. the previous head, so "## CI at this head" is not this head. The size figures were independently re-measured here as unchanged (6 / 418), so only the references need refreshing to 32df3bfa (and the current quick run) or an explicit note that the census and CI conclusion carry over unchanged.

  2. registry.ts:210-212 — correct or delete the "could only report a refusal and return the same id" clause for the 'standard' branch; as shown in item 7, the walker would not report a refusal for desktop-v2.

The resolveSkinId doc comment's T198 bullet claimed that routing the
'standard' branch through resolveWithFallback "could only report a
refusal and return the same id". The opposite holds: desktop-v2's
manifest declares all four TOPOLOGY_CLASSES, so admitsLiveTopology
admits it without deriving anything and no refusal could be reported.
Delete the clause; keep "the walk's own endpoint", which the fallback
walk's DEFAULT_SKIN_ID landing establishes.

Narrow the same drift in resolveWithFallback's doc comment: the deleted
list claimed an unregistered candidate "continues down that candidate's
own chain", but a built-in unregistered id is admitted outright (no
manifest excludes anything) and a non-built-in unregistered id ends the
walk at DEFAULT_SKIN_ID — the walk-termination tests pin both. The
replacement states only the checked mechanics: a hop requires built-in
and admitted, every other candidate's own fallbackLayoutId is followed,
and a candidate naming none ends the walk at DEFAULT_SKIN_ID.

Comment-only change; no code or test behavior touched.

Part of MOR-2425

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

morozsm commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Agent Review: PASS 6793ca0

Head confirmed: git rev-parse HEAD = 6793ca0940d17da0b3e80e0a838d1104de87ca8e, equal to gh pr view 3413 --json headRefOid (OPEN 6793ca0940d17da0b3e80e0a838d1104de87ca8e).

1. Delta is comment-only

$ git diff 32df3bfa 6793ca09 --numstat
6	7	frontend/src/skins/registry.ts

$ git diff 32df3bfa 6793ca09 --name-only
frontend/src/skins/registry.ts

One file, comments only; the only changed hunks are the resolveWithFallback docstring and the resolveSkinId bullet. File count against main is still 6:

$ git diff origin/main...HEAD --name-only | wc -l
       6

2. My finding — remainder "the walk's own endpoint" is established

The deleted clause is gone and nothing replaced it:

- *   'desktop-v2' directly — the walk's own endpoint, so routing it through
- *   the walker could only report a refusal and return the same id
+ *   'desktop-v2' directly — the walk's own endpoint.

The remainder is true at this head:

  • registry.ts:103const DEFAULT_SKIN_ID: SkinId = 'desktop-v2'; ("The skin an unresolvable preference lands on").
  • resolveWithFallback initialises mounted to DEFAULT_SKIN_ID and returns it whenever the walk takes no admitted hop (registry.ts:176-188).
  • desktop-v2 declares all four classes (desktop-declarations.ts:161), so admitsLiveTopology('desktop-v2', …) short-circuits true at registry.ts:139 for any capabilities — the walker would return 'desktop-v2'.

So the 'standard' branch's direct return 'desktop-v2' (line 230) is indeed the walk's terminal default.

3. The new tail sentence — checked as a fresh claim

New text (registry.ts:163-167):

 * A hop is taken only when it is both a built-in skin and admitted; every
 * other candidate's own `fallbackLayoutId` is followed instead, and a
 * candidate that names none ends the walk at `DEFAULT_SKIN_ID`. `visited`
 * bounds the walk: a chain that names an id already seen stops there instead
 * of looping.

It matches the code exactly (registry.ts:178-187):

    if (BUILT_IN_SKIN_IDS.has(candidate) && admitsLiveTopology(candidate, capabilities)) {
      mounted = candidate as SkinId;
      break;
    }
    candidate = getLayout(candidate)?.fallbackLayoutId ?? null;

Both sub-cases:

  • Non-built-in unregistered id → ends at DEFAULT_SKIN_ID. Not in BUILT_IN_SKIN_IDS, so the guard fails and getLayout(candidate) is undefined?? null → loop ends → mounted stays desktop-v2. Pinned by the shipped test registry.test.ts:645-649: register('dual-sdr-face', 'fallback-absent')expect(resolveFresh('dual-sdr-face')).toBe('desktop-v2').
  • Built-in id with no manifest → admitted and mounted. admitsLiveTopology returns true for an undefined manifest (registry.ts:137), and dual-sdr-face is in BUILT_IN_SKIN_IDS (SKIN_LOADERS key at line 330, set at line 343), so the guard holds and the id is mounted. dual-sdr-face is the only built-in id without a manifest (the ten registerLayout calls cover sdr-test, flagship-probe, desktop-v2, dual-receiver-cockpit, lcd-cockpit, lcd-scope, mobile, peer-split, unified-instrument, panadapter-first), and no shipped manifest names it as a fallback, so the hop role is not pinnable by a test: registerLayout rejects overriding a shipped id — my throwaway double register('flagship-probe', 'dual-sdr-face') failed with Error: Layout id "flagship-probe" is already registered. The same predicate on the entry id is pinned at registry.test.ts:588-591 (getLayout('dual-sdr-face') undefined; resolves to itself). Sub-case (a) is therefore established from the code, not from a test.

The old wording was false for both: for a no-manifest built-in the id is taken as the mounted hop (it has no "own chain" to continue down), and for a non-built-in the walk ends at the default rather than traversing a chain. The correction is itself correct.

4. Rest of the file's comments at this head

Re-read and checked, all true: reportedRefusals (throttle keyed on layout + live class; App.svelte:77 has the $derived(resolveSkinId(...))), DEFAULT_SKIN_ID, liveTopologyClass (radio-view-model-adapter.ts:1867 composes the same `${structuralCount}/${scheme}`), admitsLiveTopology (no-manifest id and all-four manifest both admitted; underivable refused), BUILT_IN_SKIN_IDS (commitExternalPresentationBatch writes into SKIN_LOADERS at line 390). No findings of the same class.

5. Check / lint

$ npm --prefix frontend run check
1789517874136 COMPLETED 4856 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS

$ npm --prefix frontend run lint
> eslint src/
(lint exit: 0)

No test run is needed for a comment-only change — none was performed as acceptance. (The throwaway sub-case-(a) double in item 3 was run and reverted; git status --short is empty afterwards.)

6. CI as found (reported, not waited on)

$ gh pr checks 3413 --required
Agent Review Gate	fail	0	https://github.com/rigplane/rigplane-core/actions/runs/35039192882
quick	pending	0	https://github.com/rigplane/rigplane-core/actions/runs/35039192901/job/104615047106

Agent Review Gate red is expected pending this directive; quick was queued/pending when observed.


REQUIRED BEFORE MERGE

PR body refresh (my earlier item #1 recurs at the new head). The body was updated to 32df3bfa, but this delta moves the head again, so its "current head" references are one behind: it says "At the current head 32df3bfa: … Agent Review Gate — pass, bound to this head", and "Census at 32df3bfa…: 6 files, 389+/29- = 418 changed lines". At 6793ca09 the census is 6 files, 388+/29- = 417 (git diff origin/main...HEAD --shortstat), and quick/Agent Review Gate are pending/fail. Refresh the head, census count and CI references (or relabel those sections as the 32df3bfa record).

@morozsm
morozsm merged commit 17d3ac9 into main Sep 16, 2026
10 of 16 checks passed
@morozsm
morozsm deleted the codex/layout-topology-gate branch September 16, 2026 00:21
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