LD3 EPIC: LD Cockpit — observer app (attach, telemetry panes, gates queue, image build) #589

Merged
aaron merged 56 commits from epic/LD3 into live 2026-10-02 21:39:23 +00:00
Owner

What

Epic-close merge for LD3: LD Cockpit — epic/LD3 → live.

Every slice on this branch was individually reviewed and landed through the pipeline. This PR is the integration-level gate: base-drift reconciliation, union-diff review, merge on green. It is a merge commit, not a squash — the slice history is preserved.

Slices contained

  • LD3.1a/b — cockpit module skeleton, CI jobs, image build workflow (+ workflow_dispatch for pre-live tags) — HOMELAB-1681
  • LD3.2a/b — Gates Queue + census + fail-closed STOP (backend); three panes (gates queue, fleet overview, director chat) + STOP (frontend) — HOMELAB-1682
  • LD3.3 — sandbox attach: bridge (built disabled), xterm.js pane, refusal postures, audited honesty — HOMELAB-1683
  • LD3.4 — derived-state visuals, telemetry panes (wave view, observed updates feed, hardened overview) — HOMELAB-1684

Union diff vs live

203 files, +41151 / -0 — all additions:

Path Status
lab-director/cockpit/** new (200 files)
.forgejo/workflows/cockpit-image-build.yaml new
.forgejo/workflows/ci.yaml +112 / -0 (additive: cockpit-test, cockpit-e2e appended)
lab-director/.dockerignore new

lab-director/dispatcher/** — untouched (0 lines in the delta).
No prod manifests (separate repo). No unrelated files.

Base drift

epic/LD3 was 31 commits behind live. live advanced .forgejo/workflows/ci.yaml (HOMELAB-1871 helm-validate steps at line ~36). Dry-run merge (git merge-tree + temp worktree) is clean — the two ci.yaml hunks are additive in disjoint regions, so the union carries both live's chart-script/render steps and the epic's cockpit jobs. Base is merged into the head before merge.

Integration review

Tracked in the comments below (fresh context, primary sources, integration lens).

## What Epic-close merge for **LD3: LD Cockpit** — `epic/LD3` → `live`. Every slice on this branch was individually reviewed and landed through the pipeline. This PR is the **integration-level gate**: base-drift reconciliation, union-diff review, merge on green. It is a **merge commit, not a squash** — the slice history is preserved. ## Slices contained - LD3.1a/b — cockpit module skeleton, CI jobs, image build workflow (+ `workflow_dispatch` for pre-live tags) — HOMELAB-1681 - LD3.2a/b — Gates Queue + census + fail-closed STOP (backend); three panes (gates queue, fleet overview, director chat) + STOP (frontend) — HOMELAB-1682 - LD3.3 — sandbox attach: bridge (built disabled), xterm.js pane, refusal postures, audited honesty — HOMELAB-1683 - LD3.4 — derived-state visuals, telemetry panes (wave view, observed updates feed, hardened overview) — HOMELAB-1684 ## Union diff vs `live` 203 files, **+41151 / -0** — all additions: | Path | Status | |------|--------| | `lab-director/cockpit/**` | new (200 files) | | `.forgejo/workflows/cockpit-image-build.yaml` | new | | `.forgejo/workflows/ci.yaml` | +112 / -0 (additive: `cockpit-test`, `cockpit-e2e` appended) | | `lab-director/.dockerignore` | new | `lab-director/dispatcher/**` — **untouched** (0 lines in the delta). No prod manifests (separate repo). No unrelated files. ## Base drift `epic/LD3` was 31 commits behind `live`. `live` advanced `.forgejo/workflows/ci.yaml` (HOMELAB-1871 `helm-validate` steps at line ~36). Dry-run merge (`git merge-tree` + temp worktree) is **clean** — the two ci.yaml hunks are additive in disjoint regions, so the union carries both `live`'s chart-script/render steps and the epic's cockpit jobs. Base is merged into the head before merge. ## Integration review Tracked in the comments below (fresh context, primary sources, integration lens).
The LD3.1a observer backend: an operator-only HTTP surface that stores nothing
and says so honestly.

- config/: environment-only schema validated as a whole, with collected faults
  and a refusal that names every one. OIDC_ISSUER empty is dev mode; ANY other
  OIDC key set while the issuer is empty is a refusal (ErrUnprotected) rather
  than an operator surface starting open because one key was missed.
- auth/: the OIDC authorization-code flow (go-oidc v3 + oauth2, the legacy
  app's plumbing pattern rewritten), a stateless AES-GCM session cookie with a
  versioned keyring (Keys[0] seals, every key opens, no auth_sessions table),
  and RequireRole(operator) over urn:zitadel:iam:org:project:roles — the piece
  the legacy app never had. 401 is JSON for an API client and a redirect to
  /auth/login for a browser; 403 is JSON for an API client and the app's own
  page for a browser.
- readmodel/: one provider, one snapshot type, and a wiring census in which
  every source is configured / unwired / error / unknown with a sentence an
  operator can act on. 3.1a ships Plane unwired by design.
- sse/: stdlib SSE — a hub whose tickers render each snapshot once for all
  subscribers, a slow consumer dropped rather than buffered, and no replay
  (whole-state read model). No WebSocket: that is reserved for LD3.3.
- server/: Go 1.22 ServeMux routing, a local middleware Chain, and the embedded
  SPA with SPA fallback plus precompressed-variant negotiation.
- cmd/cockpit/: the process, with --version and a hermetic --check that does not
  attempt OIDC discovery (a build has no IdP) and says so when one is set.
71 tests, no network and no wall clock a test cannot inject.

What the suite is actually pinning, rather than counting:

- config: the ambiguous-OIDC refusal (ErrUnprotected) with the offending keys
  named, every fault collected in one pass, the keyring grammar (base64 of
  exactly 32 bytes, no duplicate keys, padded and raw both accepted), and that
  Plane's wired/unwired state is a property of the VALUES rather than a flag.
- auth: seal/open round trip, tamper detection (a flipped ciphertext byte is
  ErrDecrypt), a rotation that reads cookies minted before it, an empty
  project-roles claim being an EMPTY role set (403) rather than a default grant,
  and the four authorization outcomes the middleware can produce. The relabel
  test documents what actually holds: the key id only chooses which key is tried
  first, so relabelling is not a bypass and a token whose key is gone cannot be
  rescued by naming one that is present.
- readmodel: uptime from an injected clock, a backwards clock clamped to zero,
  an unwired source naming the keys that would wire it, and the class vocabulary
  derived from the dispatcher module rather than re-declared here.
- sse: monotonic ids, fan-out, a stalled subscriber DROPPED rather than buffered
  (and the healthy peer surviving), and the wire framing of hello/readmodel/
  heartbeat through a real HTTP server — plus a concurrent publish-vs-cancel test
  whose assertion is the race detector itself.
- server: the health payload taking its process facts from the same provider the
  read model uses, the SPA fallback rules (a missing asset is a 404, a missing
  route is the shell, /api/* is JSON), precompressed negotiation for both assets
  and the shell document, cache headers including the service worker's no-cache,
  the security headers, and the middleware chain's order.
The operator-facing half (class D): SvelteKit 2 / Svelte 5 runes / Tailwind 4 /
bits-ui, built to a static SPA the Go binary embeds.

Design, deliberately not inherited from the legacy app:

- One accent (sodium amber) on a cold graphite ramp — NOT the legacy #3b82f6,
  which is the most common AI-default accent and the one ui-craft names. Radius
  STEPS (4px chip, 6px control, 10px pane) rather than one stamped value.
- The signature is the HATCH: every surface with no backing observation is drawn
  hatched rather than filled or hidden, so "we cannot see this" is a texture the
  eye learns. It is used for unwired sources, the empty panes and the dev-mode
  banner. The derived-state vocabulary inherits it in LD3.4.
- The header's LAMP: an 8px square that is lit when the stream is live. Status is
  never colour-only — the connection dot also spells out its state (WCAG 1.4.1).
- The three panes state their own emptiness AND their owner ticket, so an empty
  pane is a status rather than a mystery, and a placeholder cannot become
  permanent by accident.

Transports and stores:

- lib/api/client.ts: one fetch with the /api prefix and one WITHOUT it for the
  session surface. (The one-prefix version called /api/auth/me, which 404s — and
  the shell rendered that as "not signed in". Found by the e2e suite.)
- stores/events.svelte.ts: an EventSource store with named-event handling,
  browser-native reconnection, a slow-consumer message, and an auth probe after
  three consecutive failures, because EventSource exposes no status and an
  expired session otherwise looks like a dropped network forever.
- stores/readmodel.svelte.ts: the ONE read model. The SSE tick and the fetch
  render the same value; apply() keeps the identity the fetch resolved, because
  the broadcast payload carries none.
- +layout.svelte: identity -> snapshot -> stream, the 401/403 routing, and the
  explicit bridge that wires stream ticks into the read-model store.
- service-worker.ts: the offline shell only. /api/* and /auth/* are network-only
  — a cached read model is a stale fleet presented as current.

Pages: / (the three-pane shell with the wiring census and the invariants
footer), /login (real navigation to /auth/login, dev-mode note), /unauthorized
(the 403 page naming the required role and the roles the session does have).
Two suites, both runnable with one command each, and neither is decoration.

Vitest (62 tests, jsdom, $app/* aliased to hand-written mocks): the API client's
error envelope, the three stores (a twenty-line FakeSource makes every branch of
the event store reachable, including the auth probe and the slow-consumer drop),
the formatting helpers, and the components that carry the design rules — the
connection dot always spells out its state, an unwired source is hatched, a pane
states its own emptiness and owner.

Playwright (17 specs, the real binary serving the real build in dev mode):
- shell: the three-pane skeleton, the panes' owned emptiness, the stream going
  live, the uptime TICKING (the whole Go-ticker -> hub -> EventSource -> store ->
  component path in one assertion), the honest wiring census, the identity chip,
  the manifest and service-worker registration, the SPA fallback, and a missing
  asset being a 404 rather than the app.
- auth: the /auth/me round trip through the dev-mode header, two distinct dev
  identities, the dev login redirect, a 401 routing to /login and a 403 routing
  to the 403 page (both driven by route interception, which is the client
  contract), and an unknown API route answering JSON.

The OIDC authorization-code round trip against Zitadel is NOT covered here: it
needs a live IdP, a client secret and a human consent screen. It is asserted in
LD3.1b's verification plan, and its server-side refusals are covered by the Go
suite, where a non-dev handler can be built without discovery.

The legacy app's e2e was dead code (not in package.json, referenced by no
workflow). This one is in devDependencies, wired as `npm run e2e`, and run by
the cockpit-e2e CI job.
Multistage: node:22-alpine builds the SPA -> golang:1.24.1-alpine builds
cmd/cockpit with the SPA staged into cmd/cockpit/static/ -> distroless/static
nonroot (uid 65532, no shell, no libc). Both base images pinned by index digest,
re-derivable with crane.

The build context is lab-director/, not the module root: the cockpit's go.mod
carries `replace ... => ../dispatcher` (R1) and the frontend lives beside it, so
the context is the one directory holding both modules. lab-director/.dockerignore
keeps it a few MB by excluding node_modules (~250 MB) and .svelte-kit.

Two deliberate details:

- The Dockerfile DELETES cmd/cockpit/static/ before copying the real build. That
  directory holds a checked-in placeholder so a Go-only checkout can build and
  test without npm; leaving it in place would ship a "frontend not built" page in
  the published image.
- The build runs --version and --check itself, so an image that cannot start its
  own validation never reaches a tag.

There is no HEALTHCHECK: distroless has no shell to run one in, and the
Deployment's probes hit /api/v1/health (a read-only GET).
ci.yaml gains three jobs (additive only; the existing jobs are untouched):

- cockpit-test: go vet + go test -race in a golang:1.24 container over the dind
  socket (the same shape and the same reason as dispatcher-test — the :host
  runner has no C toolchain, and the SSE hub's drop/cancel behaviour is a race
  property that must be enforced by CI, not only locally). The whole
  lab-director/ tree is mounted because the module replaces ../dispatcher.
- cockpit-test (web): a node:22-alpine container pinned to the SAME digest the
  Dockerfile builds with, so CI's node and the image's node cannot drift; the
  checkout is mounted read-only and copied inside so node_modules never lands
  root-owned. svelte-check is required, not a nicety: the e2e specs are
  type-checked and a type error there is a test that silently asserts nothing.
- cockpit-e2e: the SPA and the binary are built on the runner's side of the dind
  socket, then the suite runs inside the digest-pinned official Playwright image
  (chromium + shared libraries + Xvfb in one maintained image, so no browser is
  installed into an unknown runner). Headed chromium under xvfb-run.

cockpit-image-build.yaml is modelled on dispatcher-image-build.yaml (same
trigger shape, preflight skip-with-reason, promotion rule, scoped HARBOR_PUSH_*
robot, size gate) with three cockpit-specific differences: the context is
lab-director/, pull_request also covers epic/** (ticket PRs target an epic
branch, and an image first built on the branch that lands it is first exercised
too late), and the smoke RUNS the image as a server — health, read model, the
embedded SPA actually being the app rather than the placeholder, and a live SSE
handshake carrying hello, heartbeat and readmodel from a sibling container. A tag
is pushed `latest` only from live; the 3.1b deploy slice pins the digest.
HOMELAB-1681: docs(cockpit): module README
Some checks failed
Plan in progress...
CI Review / helm-validate (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 2m36s
CI Review / cockpit-test (pull_request) Successful in 2m53s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 2m59s
CI Review / cockpit-e2e (pull_request) Successful in 4m2s
a503fa7367
Package map, how to run each suite, the environment schema and the two keys that
decide the posture (OIDC_ISSUER, COCKPIT_COOKIE_KEYS), the authorization
outcome table, the realtime contract (one tick for N clients, drop the slow
consumer, no replay), the wiring census as it stands in 3.1a, and the explicit
list of what this slice does not do.
HOMELAB-1681: fix(ci): the image smoke's readiness grep never matched
Some checks failed
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 1m50s
CI Review / cockpit-test (pull_request) Successful in 2m9s
CI Review / dispatcher-test (pull_request) Successful in 2m35s
CI Review / cockpit-e2e (pull_request) Successful in 3m46s
6f2c9287c8
Run #1665 (the workflow's first run) failed the 'image serves and streams' smoke
against a container that had been healthy for the whole 30s wait: the readiness
loop grepped for slog TextHandler's QUOTED form (msg="listening"), but
TextHandler only quotes a value that needs it — a value with a space
(msg="session keyring") is quoted, a single word (msg=listening) is not.

Every other step in the run was green, including both probes of the built image
(--version and --check) and the CI test/e2e jobs, so the image itself built and
validated correctly; only this wait was wrong.

The pattern now accepts both forms and the comment records the quoting rule, so
the next reader does not reintroduce it.
HOMELAB-1681: fix(ci): the image smoke probe used the wrong hostname
Some checks failed
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 5s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 9s
CI Review / cockpit-test (pull_request) Successful in 2m10s
CI Review / dispatcher-test (pull_request) Successful in 2m11s
CI Review / cockpit-e2e (pull_request) Successful in 3m26s
503798dd83
Run #1668 cleared the readiness wait (the previous fix worked) and then failed
inside the probe with

  urllib.error.URLError: <urlopen error [Errno -5] Name has no usable address>

which reads as a DNS fault on the smoke network but was a typo: the probe opened
http://cockpit:8080 while the container on that network is named
ld-cockpit-smoke-server. The container name is now passed to the probe as argv,
so the two cannot drift again, and the probe prints the base URL it is about to
use so the next failure of this kind names the host in its first line.

(The dispatcher workflow's identical stub pattern interpolates ${stub} into the
container under test's environment, which is why it does not have this bug.)
HOMELAB-1681: fix(ci): the image smoke asserted a non-dev read-model shape
All checks were successful
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 5s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 19s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 17s
CI Review / cockpit-test (pull_request) Successful in 1m34s
CI Review / dispatcher-test (pull_request) Successful in 2m7s
CI Review / cockpit-e2e (pull_request) Successful in 3m39s
b688ecf399
Run #1670 got all the way through the smoke (health, read model, the embedded SPA
being the app rather than the placeholder, and a live SSE handshake carrying
hello/heartbeat/readmodel) and failed on one assertion that was simply wrong:

  FAIL readmodel has no identity without a session
       {"subject": "dev:dev-anonymous", ...}

The image is started in dev mode by design (no credential, no IdP to reach), and
dev mode is unprotected by definition — so an identity IS the expected answer
with no cookie and no dev header. The "no identity" case belongs to a non-dev
handler, which the Go suite asserts directly.

What the smoke now asserts instead is the property that actually matters for an
observer app: an identity that did not come from an IdP advertises itself as one
(dev: true, subject prefixed dev:) and never renders as a real login.
HOMELAB-1681: fix(cockpit): cover inline SPA bootstrap with CSP sha256 hashes (review B1)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 21s
CI Review / cockpit-test (pull_request) Successful in 1m59s
CI Review / dispatcher-test (pull_request) Successful in 2m14s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m28s
CI Review / cockpit-e2e (pull_request) Successful in 3m39s
2e0d9b8aad
The deployed (non-dev) CSP emitted `script-src 'self'`, which blocks the
inline SvelteKit bootstrap that calls kit.start() and registers the service
worker. The authenticated cockpit therefore rendered a blank page; dev mode
(whose CSP carries 'unsafe-inline') hid the defect because every test served
the document in dev mode.

server/static.go now derives the CSP hash of every inline script from the very
bytes that are served (quote-aware, comment-aware byte scanner; `</script`
ends the content, as the tokenizer rules), and server/middleware.go appends
them to `script-src` in the deployed policy only. 'unsafe-inline' and nonces
stay out.

server/csp_test.go is the regression test that would have caught B1: it runs
with Dev:false, serves the real web/build document when it exists (and the
SvelteKit-shaped shell otherwise), and asserts the served CSP's hashes cover
exactly the served document's inline scripts, count > 0. Verified against the
real build in headless Chromium under the production CSP: bootstrap runs, app
mounts.
HOMELAB-1681: fix(cockpit-web): service worker install, real SW e2e, responsive shell (review findings)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / cockpit-test (pull_request) Successful in 1m54s
CI Review / dispatcher-test (pull_request) Successful in 2m18s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 3m30s
CI Review / cockpit-e2e (pull_request) Successful in 3m47s
a6fd65feb4
The class-D visual check on 2e0d9b8 found one blocker and three
responsive/accessibility defects that the code reviews could not see,
because none of them is visible to a unit test. All four are in
lab-director/cockpit/web/**; no Go, server or CI change.

1. BLOCKER — the PWA service worker never installed. `service-worker.ts`
   used SvelteKit's `build`/`files` globals without importing them from
   `$service-worker`, so the built worker evaluated `[...build,...files,
   "/index.html"]` against undefined identifiers, threw a ReferenceError
   during registration, and was discarded: `getRegistrations()` stayed 0,
   `navigator.serviceWorker.ready` never resolved, and every page load
   logged "ServiceWorker script evaluation failed". The offline shell —
   the PWA's only behaviour, and a named 3.1a deliverable — did not exist.
   Fix: the documented `import { build, files, version } from
   '$service-worker'`, and the cache name now carries `version` so a
   deploy gets a fresh cache instead of a manual `-v1` bump.

2. The e2e that claimed to cover the PWA was vacuous (shell.spec.ts:95).
   It asserted only `getRegistrations().length > 0`, which passed in 194 ms
   on a transient registration object that died immediately. It now
   asserts reality: `navigator.serviceWorker.ready` resolves inside a 20s
   bound, the registration is `activated` and scoped to the origin, the
   page is actually controlled, and then — with the network emulated
   offline — a reload is answered from the worker's own cache with the
   wordmark and all three panes, while `/api/v1/readmodel` is NOT served
   from any cache and the shell renders its read-model error. Mutation
   proof: the spec fails against the pre-fix worker with "ready did not
   resolve within 20s (ServiceWorker script evaluation failed)" and passes
   against the fix.

3. HIGH — mobile horizontal overflow. Every route was 503px wide in a
   390px viewport (113px of sideways scroll) because the header could not
   wrap or shrink; the offending boxes were the identity chip and the
   sign-out button. The header is now a wrapping flex row (wordmark row +
   right-aligned status row on a phone, the single 48px row unchanged at
   1440px), with `min-w-0`/`truncate` on the identity chip so a long name
   shrinks instead of pushing.

4. MEDIUM — targets below the 24px floor and small-text contrast below
   4.5:1. The dev badge, the identity chip, the census status triggers and
   the wordmark were 16-21px tall; they now take a stated floor
   (`--target-min` 24px for dense rows, `--target-comfortable` 36px in the
   header — 44px would cost a 2xl header on a 390px screen). `--ink-faint`
   measured 3.91:1 on the pane surface and 3.24:1 on a hatch stripe; the
   ink steps move to 0.65/0.75, which clears 4.5:1 on ground, surface,
   raised and the hatch (worst case 4.7:1 / 6.8:1).

New net: e2e/responsive.spec.ts measures all three properties in the
browser on `/`, `/login` and `/unauthorized` at 390 and 1440. Mutation
proof: all 9 specs fail against the pre-fix UI (scrollWidth 503 vs 390,
sub-24px controls, sub-4.5:1 text) and pass after.

Verified: svelte-check 0 errors/warnings; vitest 62 passed (9 files);
playwright 26 passed (was 17); `go build ./... && go test ./server/...`
still green. Token contrast computed with an oklch->sRGB solver and
re-measured through canvas compositing in the real build.
The three seams LD3.2a is built on, all credentialless:

- plane/: a three-method GET-only read seam (issues, issue, comments) with a live
  client that reuses the dispatcher's watch.HTTPClient for
  issues/states/labels/project and adds only the comment read (the gate model is
  a comment marker, and that client cannot read comments). State-group filtering
  is client-side, because the proxy ignores state__group; a partial scan is a
  named error, never a short queue. The write seam is a SEPARATE interface
  (Writer) whose production implementation in this slice is the named refusal, so
  a read token and a write token cannot arrive through one type.
- gates/: ld.gate/v1 (options, recommendation, exposure{blast,recovery},
  expires_at, gate_owner, nonce) and ld.gate-disposition/v1 as markers inside
  Plane comments, mirroring claims' record discipline: append-time and read-time
  validation, newest-wins with an ascending-nonce tiebreak, and a pure Resolve
  for the lifecycle (open / answered / expired). Refusals are typed and carry
  the policy line (P-GATE-001..006) they offend.
- audit/: the ld.audit.sentinel/v1 entry shape with an interim log sink, so the
  entry an operator will eventually query in JetStream is the entry being emitted
  today. Verified separates attribution from evidence: a dev-mode header identity
  is named and marked unverified.

Tests: the Plane wire format (httptest loopback, no Plane), the gate lifecycle
(open->ratify, open->defer->re-gate, expiry, a disposition older than its gate, a
foreign nonce), record validation, the store's enumeration and every decision
refusal, and the ledger shape.
Four routes on the existing ServeMux discipline, all operator-only:

  GET  /api/v1/gates                        the queue + the write seam's posture
  POST /api/v1/gates/{ticket}/disposition   one decision, written as a comment
  GET  /api/v1/overview                     wiring census + counts census
  POST /api/v1/fleet/stop                   R8: 503 "sentinel not armed", always

The queue answers 200 with an honest state in EVERY case, including unwired and a
failed read: the state IS the answer, and a 500 would say "the cockpit is broken"
when the truth is "the cockpit cannot see the queue yet, and here is why". A
decision is different — it is refused with a status a client can branch on (503
unwired, 404 unknown ticket, 410 expired, 409 no-gate/foreign-nonce/
already-decided, 400 malformed, 502 write failed) plus the policy line that
decided it, and the principal always comes from the session, never the body (a
body-supplied principal is a 400, not an accepted forgery).

The census keeps measured counts and leaves unmeasured ones ABSENT, never zero:
a zero is a measurement, and an outage is not a measurement of zero. The read
model's wiring census gains its own gates row, separate from plane, so an operator
can see the queue reading while a decision still cannot be written.

The STOP endpoint invokes nothing: it appends an ld.audit.sentinel/v1 entry
(kind stop, outcome refused, P-SENT-001/P-SENT-002) before it answers, reports
audit_recorded honestly when the sink refuses, and has no arm flag and no override.

Config: PLANE_WORKSPACE (defaulted to this instance's workspace) is the one new
key; a wrong workspace is a loud 404, never a silently different project.
The package map, the route table, the honest-state table (plane + gates rows,
absent != zero), the two record shapes with the rules they enforce, and what the
write seam's 503 means. The tests section names what LD3.2a covers.
HOMELAB-1682: test(cockpit): the gate model, its lifecycle and every refusal
Some checks failed
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / cockpit-test (pull_request) Successful in 1m38s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m58s
CI Review / dispatcher-test (pull_request) Successful in 2m10s
CI Review / cockpit-e2e (pull_request) Failing after 2m11s
0e3f47922c
Fixture-driven and clock-pinned: record/disposition validation for every
unusable shape, marker round-trips and corrupted markers, newest-wins with the
nonce tiebreak, the lifecycle (open -> ratify; open -> defer -> a new gate
reopens; expiry closes and says so; a disposition older than its gate answers
nothing; a foreign nonce does not close the gate), the store's enumeration (class
E by label, by prose, by marker, decided, closed, paginated, comment budget), and
the decision path: one comment, idempotent by (nonce, decision, principal),
refusals typed and cited, a failed write never reported as applied.
HOMELAB-1682: test(cockpit-web): assert the four named read-model source rows
All checks were successful
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 21s
CI Review / cockpit-e2e (pull_request) Successful in 1m36s
CI Review / cockpit-test (pull_request) Successful in 1m41s
CI Review / dispatcher-test (pull_request) Successful in 2m6s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m54s
b3c1beca3c
HOMELAB-1682: fix(cockpit): review blockers — audit schema shape, unwired count absence, cross-principal idempotency
All checks were successful
CI Review / receiver-test (pull_request) Successful in 7s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m0s
CI Review / cockpit-test (pull_request) Successful in 1m56s
CI Review / dispatcher-test (pull_request) Successful in 2m7s
CI Review / cockpit-e2e (pull_request) Successful in 4m16s
c11c7254fc
Three verified landing-review blockers on PR #572, each fixed at its source.

B1 — the audit entry claims `ld.audit.sentinel/v1` but emitted lookalike
strings. `audit.Entry.Principal`/`Scope` are now the schema owner's own types
(`sentinel.Principal` / `sentinel.StopScope`), `SchemaSentinel` is the sentinel
package's constant rather than a re-typed literal, and `Entry.Validate` checks
the principal and the scope against that vocabulary ("fleet" was never a scope
this schema declares). The STOP endpoint builds the entry from the session:
verified session → `operator:<subject>`, dev-mode header identity → `unknown`
with `verified:false` (attribution recorded, authority not claimed), unnamed →
`unknown/unattributed`; scope is `{"kind":"all"}`. New test decodes every
emitted entry into `sentinel.AuditEntry` and runs the schema's own Validate, in
both directions.

B2 — `gates.Queue.Count` was a non-pointer int, so an unwired or failed queue
rendered a fabricated `"count":0`, contradicting R4 / P-COCKPIT-005 (the line
this PR adds) and the census half. Count is now `*int` + `omitempty`: absent
when unwired/error, present when measured. `CountOpenGates` refuses to turn a
missing measurement into a zero; the doctor command prints "unmeasured".

B3 — the principal leg of the idempotency key had no test. New test: operator B
replays operator A's exact decision on the same gate/nonce and is refused
`already_decided`, nothing is attributed to B (one comment, A's), and A's own
retry is still idempotent. Verified load-bearing by mutation: removing the
`EqualFold(principal)` comparison makes the new test — and only it — fail.
The frontend half of LD3.2 starts at the contract, not at the pixels:

- `types/api.ts` mirrors the 3.2a Go shapes field-for-field (gates.Queue/Item/
  WritePosture, the disposition result and both refusal families,
  readmodel.Census, the fail-closed stop response). Every count that Go omits
  when it could not measure is OPTIONAL here, which is what makes the panes'
  absence-not-zero rule a type error rather than a convention.
- `stores/gates.svelte.ts`: the queue, the closed decision vocabulary, and the
  disposition flow. Three properties are load-bearing and tested: an unmeasured
  count is null (never 0), `decisionsFor()` intersects the payload with
  ratify/defer/veto so a class-E row can never render a veto or an automatic
  path, and every refusal (503 unwired, 409 no gate, 410 expired, 400, 502) is
  recorded against the row it was about instead of being swallowed.
- `stores/overview.svelte.ts`: the census read, plus `measured()` — the one
  function the panes count through.
- `stores/stop.svelte.ts`: the fleet stop, whose 503 is a first-class refusal
  carrying the ledger entry the cockpit wrote about its own attempt.
- `api/chat.ts`: the Director chat transport seam (`send(message)`) and the
  refusing implementation this build ships, naming LD4.3 (HOMELAB-1687).
- `stores/panes.svelte.ts`, `honest-state.ts`, `format.ts` (+ expiry countdown),
  the confirm dialog (dangerous → confirm), one new status tone for DONE-UNSEEN,
  and a `data-decision` hook on Button so the decision vocabulary is asserted
  from the DOM.
The shell's three panes become the real surfaces, and the 3.1a placeholder
frame (Pane + its empty-state contract) is superseded by them — each pane now
names its own missing dependencies in place, which is what the placeholder was
standing in for.

- Gates Queue (the centerpiece): rows with recommendation, exposure bound
  (blast + recovery), options, expiry countdown, owner and nonce, plus the
  ratify/defer/veto controls from the closed vocabulary. Class-E rows render
  ratify/defer; a class-E ticket with no gate record renders NO controls and the
  sentence explaining why. The unwired state says "gates unwired" and names
  LD3.1b; the write posture is stated before it is discovered via a 503.
- Fleet Overview: the three card groups (homelab / fleet / updates), every count
  through StatCell — a hatched ? when the backend did not measure it, never a
  zero — the wiring census as named rows, the project switcher, the sandbox
  table skeleton whose only row is the honest unknown (hatched, never green),
  and the four-state vocabulary legend.
- Director chat: text-first, with the transport seam wired to the refusing
  implementation, so a typed message is recorded as NOT SENT with the reason
  (LD4.3 / HOMELAB-1687) and no reply is invented.
- Header STOP: always visible, one confirmation dialog naming fleet scope and
  the ledger entry, then the real fail-closed request. The 503 renders amber
  with the audit entry echoed back (schema, outcome, policy lines, principal,
  verified, request id).
- Pane navigation: a tablist under the header. All three panes stay mounted
  (inactive ones hidden) so their state survives a glance and the shell's
  structure is stable across routes; the gates badge renders only a measured
  count.

The emoji-block glyphs the design names (⏸ ✋ ⏹) have no font coverage in this
stack — verified in a browser, they paint as tofu — so the derived-state marks
are lucide strokes and the STOP mark is a drawn square. Intent preserved, tofu
avoided.
65 new tests over the stores and the panes, each asserting a design ruling
rather than a rendering detail:

- an unwired queue exposes NO count (the payload omits it; the store returns
  null) and the pane says "count not measured" instead of "0 need you";
- the decision vocabulary is closed — a class-E row offers ratify/defer even if
  the payload claimed a veto, a needs-gate row offers nothing, and an unoffered
  decision is refused locally without a request;
- every refusal family lands on its row with the backend's kind, reason, policy
  line and wiring;
- the dashboard renders no fabricated number for any cell without a source, and
  `unknown` is never rendered with the healthy tone;
- a chat message is NOT SENT, with the transport's sentence, and there is no
  assistant turn to be mistaken for a reply;
- the STOP dialog requires the fleet-scope confirmation and the refusal banner
  carries the audit entry, including the unverified dev identity.

`src/lib/tests/fixtures.ts` is the single source of the wire shapes for BOTH
suites (relative imports so Playwright needs no $lib alias), so the unit
fixtures and the e2e payloads cannot drift.
18 new Playwright specs against the real binary and the real build (dev mode),
plus the 3.1a shell spec adapted to the shell that now exists:

- the tablist mounts all three panes, switches them, and is keyboard navigable;
- the gates pane renders the unwired-honest state naming LD3.1b, with no count
  and no rows; the gates tab carries no badge it cannot back;
- the dashboard renders census rows without fabricating a count, names every
  source, and keeps `unknown` off the healthy tone;
- the chat pane names LD4.3/HOMELAB-1687 and marks a sent message not sent;
- STOP: confirm (fleet scope, SIGSTOP, the audit preview) → the amber
  not-armed banner with the ledger entry echoed back → dismissable;
- every pane stays inside a 390px viewport and clears the 24px target floor.

The configured queue cannot exist on the live server (it has no Plane
credential by design — LD3.1b), so `gates-configured.spec.ts` fulfils
/api/v1/gates and /api/v1/overview in the browser with the EXACT payloads the
Go tests assert, from the fixtures the unit suite shares. That covers rows,
exposure bounds, class-E decisions, the required reason and the refused
disposition end-to-end against the production frontend build.
HOMELAB-1682: test(cockpit-web): component specs for the panes, the tabs and the STOP
Some checks failed
CI Review / helm-validate (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 10s
CI Review / alert-receiver-test (pull_request) Successful in 27s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m4s
CI Review / cockpit-test (pull_request) Successful in 2m11s
CI Review / dispatcher-test (pull_request) Successful in 2m45s
Plan in progress...
CI Review / cockpit-e2e (pull_request) Failing after 3m47s
f4183d03d8
The remaining unit layer: what each pane renders for each honest state (unwired,
error, measured-empty, configured), what the confirmation dialogs say before a
decision is taken, that the reason gates the confirm on both dangerous paths,
and that the tablist's selection and badge behave.

One real defect found by writing them: the written-decision line rendered
"ratifyd" (a past tense built with string concatenation). It is a label map
now, with the test that caught it.
HOMELAB-1682: fix(cockpit-web): reconcile with the merged 3.2a audit shape, de-rot the queue fixture
All checks were successful
CI Review / helm-validate (pull_request) Successful in 13s
CI Review / receiver-test (pull_request) Successful in 14s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 10s
CI Review / alert-receiver-test (pull_request) Successful in 26s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m23s
CI Review / dispatcher-test (pull_request) Successful in 2m11s
CI Review / cockpit-test (pull_request) Successful in 2m4s
CI Review / cockpit-e2e (pull_request) Successful in 2m22s
aba6709bfb
Sequencer merge of epic/LD3 (which now carries LD3.2a, PR #572) left the slice
red on two counts. Both are integration fixes, neither is a defect in the
builder's panes:

1. c11c725 made audit.Entry.Principal the sentinel's Principal OBJECT
   ({kind,id,display}) and Entry.Scope a StopScope object. The stop banner
   interpolated the field straight into the DOM, so the ledger echo painted
   "principal [object Object]" and panes.spec.ts:204 failed against the real
   server. The types now model AuditPrincipal/AuditScope, and the banner renders
   the identity the ledger is grouped by (principal.id) alongside the schema's
   own kind (unknown for an unverified dev identity). A regression assertion
   pins the object read so "[object Object]" cannot come back silently.

2. gates-configured.spec.ts asserted the SEND-472 countdown contained "23h", but
   the fixture pinned expires_at to a fixed instant, so the live countdown walked
   out of that bucket ~1h after the branch's own CI passed it (it went red at
   05:00Z). Fixture expiries are now offsets from a single process-wide anchor:
   deterministic within a run (gateItem() is called twice and the results are
   compared) and still a real future deadline, which is what a server emits.

Verified locally on the merged head: svelte-check 0 errors/0 warnings, unit
137/137, build OK, e2e 43/43 against a real cockpit binary built from this tree,
plus cockpit + sentinel Go tests green.
HOMELAB-1682: fix(cockpit-web): review blockers — identity after tick, measuredness contract, never-auto structure, census fixture parity
Some checks failed
CI Review / helm-validate (pull_request) Successful in 16s
CI Review / receiver-test (pull_request) Successful in 12s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 12s
CI Review / alert-receiver-test (pull_request) Successful in 25s
CI Review / cockpit-test (pull_request) Failing after 1m32s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m10s
CI Review / dispatcher-test (pull_request) Successful in 2m20s
CI Review / cockpit-e2e (pull_request) Failing after 2m18s
a884e78506
Fixes the four blockers from the PR #578 dual review.

1. Identity falsehood after the SSE tick. The pane rendered
   `snapshot?.identity`, but every read-model tick is `Snapshot(nil)` by
   design (one payload serves every subscriber), so ~1s after load the
   process strip claimed "no identity resolved" while the operator was
   authenticated. The pane now renders the store's merged identity
   `readmodel.identity` (passed by the page) with the snapshot's own as a
   first-paint fallback, and a skeleton while the fetch is in flight.

2. "No fabricated zeros" was a false green. Measuredness is now derived
   from the SOURCE state (`censusWired && measured(...)`), never from the
   value: an unwired census that emits `0`s renders the hatched `?` and
   "not measured", not `0 tickets seen`. The live spec no longer branches
   on the component's own `data-measured` flag — the circularity that let
   a zero-emitting census pass 12/12; absence is now asserted directly,
   plus a new spec that serves the mutated (zeros + unwired) payload.

3. The never-auto guard grepped button textContent. The gates list now has
   a structural contract: every actionable element inside
   `[data-testid="gate-list"]` must be a `[data-decision]` in
   ratify|defer|veto or on a short explicit whitelist. An icon-only
   control, a fourth un-named button and an `<a href>` all fail now.

4. The census fixture contradicted the wire (3 rows / 81 beside
   `total: 128`). Go emits one row per `plane.Groups()` — always five, in
   render order — and counts every issue into `total`. The fixture now
   matches (5 rows summing to 128), `dashboard-pane.test.ts` pins the
   length and order, and a new `fixtures.test.ts` parses `plane.Groups()`
   from the Go source so the next backend row addition fails loudly.

Verification: svelte-check 0/0; vitest 143/143 (19 files); build OK;
Playwright 45/45 against a cockpit binary built from this tree (Go tree
identical to origin/epic/LD3); 390px scrollWidth 390/390 doc, 358/358 per
pane. Each guard is mutation-proved in the landing log.
HOMELAB-1682: fix(cockpit-web): wire the fixture parity guard to the real tree in CI, de-flake the SW activation wait
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m41s
CI Review / cockpit-e2e (pull_request) Successful in 1m53s
CI Review / cockpit-test (pull_request) Successful in 2m4s
CI Review / dispatcher-test (pull_request) Successful in 2m13s
694e8a7355
HOMELAB-1683: feat(cockpit): LD3.3 sandbox attach bridge, built disabled
Some checks failed
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Has been cancelled
CI Review / cockpit-test (pull_request) Has been cancelled
CI Review / cockpit-e2e (pull_request) Has been cancelled
Build Lab Director Cockpit Image / build-and-push (pull_request) Has been cancelled
1e59acd576
Adds the attach bridge behind LD_ATTACH_ENABLED (default false): the
cockpit/** mechanism, the server wiring and the refusal matrix, with no
manifests, no RBAC, no CNP, no dispatcher change and no web/**.

attach/ (new):
- Registry seam (read-only interface, recording fixture, and the refusing
  unwired registry that names the unratified RBAC) — the live CRD reads
  wait for the operator's ratification package §4/§5.
- Executor seam + client-go impl: remotecommand.NewSPDYExecutor over
  pods/exec with Tty:true and a TerminalSizeQueue; construction reads a
  kubeconfig (LD_ATTACH_KUBECONFIG, empty = in-cluster) and contacts
  nothing.
- Refusal matrix with a P-ATT-001..008 policy book, the class harness
  allowlist (spawn's own class constants; unknown class refuses), and the
  ld.audit.attach/v1 writer (audit.Entry gains attach kinds, outcomes and
  fields; Validate enforces the schema/kind/outcome pairing both ways and
  keeps sentinel entries free of attach fields).
- Wire() is the one place a kube client can be constructed: disabled
  builds none (asserted by a construction counter), enabled builds one and
  refuses per attempt while the registry is unwired.

server/:
- GET /api/v1/attach/{sandbox}/info and WebSocket /ws/attach/{sandbox},
  registered outside RequireOperator so that a refused handshake is still
  a ledger entry; identity resolution, refusals and the WS protocol all
  audit (open/close/refused), statusLogger gains the Hijack passthrough a
  websocket upgrade needs through the chain.
- One attach_open is answered by exactly one attach_close (a failed dial
  writes the aborted close), and an attach never opens when its open entry
  cannot be written.

config: LD_ATTACH_ENABLED (default false) + LD_ATTACH_KUBECONFIG; a
kubeconfig configured while the flag is off is refused as ambiguous.
main: attach declared in the read-model census, in --check's OK lines and
in the boot log ("no kube client constructed").

Tests: 192 pass under -race (attach 32, audit 9, server 47, config 13 new
or extended); full refusal matrix, WS protocol over a real socket
(upgrade/resize/EOF both ways/abrupt disconnect/protocol error/slow
consumer), ledger pairing and entry JSON shape.
HOMELAB-1683: fix(cockpit): bound the attach socket read frame at the library, label the refusal log by policy
Some checks failed
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 19s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 54s
CI Review / dispatcher-test (pull_request) Successful in 2m36s
CI Review / cockpit-test (pull_request) Successful in 2m49s
CI Review / cockpit-e2e (pull_request) Failing after 2m50s
f565272bf0
HOMELAB-1681: ci(cockpit): workflow_dispatch build path for pre-live image tags
All checks were successful
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m8s
CI Review / cockpit-test (pull_request) Successful in 2m53s
CI Review / dispatcher-test (pull_request) Successful in 3m24s
CI Review / cockpit-e2e (pull_request) Successful in 5m54s
9a874cb296
The deploy slice (LD3.1b) pins a DIGEST, so the digest has to exist before the
manifests can name it — and the cockpit image workflow's only push trigger is
`live`, which the epic has not reached. This adds the dispatch path that
publishes an epic/LD3 build.

ADDITIVE, by construction:

- `workflow_dispatch` gains one optional `ref` input (default "" = build the
  ref the dispatch ran on, i.e. the branch picked in the UI). A new step in
  BOTH jobs checks out a non-empty `ref` with a shallow single-ref fetch and
  `--detach`, so preflight answers "is the cockpit on THIS ref?" for the ref
  that will actually be built.
- `SHORT_SHA` now comes from `git rev-parse HEAD` instead of
  `${{ github.sha }}`. Same seven characters on the push/PR paths (checkout
  HEAD IS github.sha there); on the dispatch path it is the BUILT commit, so
  the tag, the smoke results and the reported digest all describe one commit.
- The `latest` promotion decision reads the BUILT ref rather than the run's
  ref. With no input the two strings are identical, so a `push` to `live` still
  promotes and nothing else does; the case this closes is a dispatch from
  `live` whose `ref` input names an epic branch, which must never move
  `latest`.

Unchanged: both existing triggers byte-for-byte (branches/paths), the
`if: github.event_name != 'pull_request'` push gate, the scoped
HARBOR_PUSH_USERNAME/PASSWORD robot (no admin credential, no new secret), the
SHA-tag-only push, and the size gate.

Verified: YAML parses (PyYAML); every `run:` block passes `bash -n`;
yamllint finding count unchanged at the file's pre-existing level (67
line-length/style findings before and after, none of a new class).
HOMELAB-1682: fix(cockpit-web): portal the STOP dialog to root, viewport-assert both dialogs
All checks were successful
CI Review / receiver-test (pull_request) Successful in 13s
CI Review / helm-validate (pull_request) Successful in 20s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 31s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m20s
CI Review / cockpit-test (pull_request) Successful in 2m50s
CI Review / dispatcher-test (pull_request) Successful in 4m9s
CI Review / cockpit-e2e (pull_request) Successful in 4m7s
609d25c55b
The class-D visual blocker on PR #578: the STOP confirmation opened 174px above
the viewport at 1440 (351px at 390), clipped to the header's 48px strip.

Cause (source-verified): `app-header.svelte` carries `backdrop-blur`, and a
`backdrop-filter` ancestor becomes the containing block for `position: fixed`
descendants. The dialog's backdrop therefore resolved against the sticky header
instead of the viewport, and the header's overflow clipped it. The gate dialog
was unaffected only because it is mounted in the pane.

Fix: `ConfirmDialog` now portals its backdrop to `<body>` via `$lib/actions/portal`
(a `use:` action), which removes the modal from every ancestor's containing block
and overflow chain at once. The header's blur token is untouched and the STOP
trigger stays in the header. Both dialogs get the guarantee.

Guard: `e2e/dialog-viewport.ts` asserts, BEFORE anything is scrolled, that the
dialog box is fully inside the viewport on all four edges and that the backdrop
covers it exactly (top 0, bottom innerHeight) — a direct viewport measurement,
because Playwright scrolls the clipping ancestor to click and stayed green.
Applied at 1440 and 390 for STOP (one test per width) and at 390 for ratify.

Evidence: pre-fix the new specs fail with {"top":-173.6,"backdropBottom":48} at
1440 and {"top":-351,"backdropBottom":128} at 390; post-fix they measure
{"top":252.4,"bottom":647.6,"backdropBottom":900} and
{"top":365,"bottom":832,"backdropBottom":844}.

Verified: svelte-check 0/0, unit 145/145 (20 files), build OK, e2e 48/48,
390px scrollWidth 390 == clientWidth (unchanged).
HOMELAB-1683: fix(cockpit): keep attach out of the read-model census until the pane slice owns its parity guard
Some checks failed
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 8s
CI Review / receiver-test (pull_request) Successful in 9s
CI Review / alert-receiver-test (pull_request) Successful in 29s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m6s
CI Review / cockpit-test (pull_request) Failing after 2m43s
CI Review / dispatcher-test (pull_request) Successful in 2m58s
CI Review / cockpit-e2e (pull_request) Failing after 3m16s
e79ed6ff4b
HOMELAB-1683: ci(rerun): no code change — re-trigger cockpit jobs after environment flakes
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m2s
CI Review / cockpit-e2e (pull_request) Successful in 2m17s
CI Review / cockpit-test (pull_request) Successful in 2m29s
CI Review / dispatcher-test (pull_request) Successful in 2m38s
0/0 projects applied successfully.
ca9022f338
Previous run (1715): cockpit-e2e failed only on the PWA service-worker
activation spec (a known flake on this base; the de-flake is 694e8a7 on the
in-flight three-panes branch) and cockpit-test failed in its npm step
(registry), while the same Go tree passed cockpit-test in run 1709 and
passes go test -race locally. Empty commit to re-run the matrix.
The frontend half of LD3.3 starts at the contract. Every shape in
types/attach.ts was read off the Go it mirrors (attach.Info,
attach.Server*Frame, attach.RefusalKind, server.attachRefusal) and then
VERIFIED by marshalling those structs and diffing the JSON — which is how
two things the Go doc comments get wrong were found:

  - attach.Term carries NO json tags, so the info payload's terminal object
    is {"Columns":120,"Rows":32} (PascalCase), NOT the cols/rows the comment
    above the type claims. normaliseTerm accepts both spellings so a future
    tag addition is a no-op rather than a silent 0x0 geometry.
  - class_without_tty is answered 200, not the refusal kind's own 409: the
    read succeeded, so a 4xx would tell the frontend its request was wrong.

attachApi.info therefore does NOT throw on non-2xx. The backend answers a
decidable refusal as HTTP so a browser's onerror and an operator's curl see
the same JSON — but a browser cannot read a failed WebSocket handshake's
status at all, which is why every 400/401/403/404/409/502/503 is a POSTURE
the button renders rather than a transport failure it retries.

xterm 6.0.0 and @xterm/addon-fit 0.11.0 are pinned exactly (both are
lazily imported, so they stay out of the home screen's first-paint chunk).
formatBytes joins the format helpers for the session status strip.
Three modules, and the split between them is the point.

protocol.ts is the wire grammar, pure: no socket, no store, no DOM. The
backend states it once (attach/protocol.go) and says why in the comment
above it — "the xterm.js pane is the other half of this contract, and a
protocol described in two places is a protocol with two dialects". The rule
the whole file hangs off: binary is data, text is control. A control frame
typed into the shell leaks the cockpit's bookkeeping; keystrokes parsed as
JSON are dropped on the floor. Both are one if-statement away.

Binary detection is STRUCTURAL (ArrayBuffer.isView plus a
Object.prototype.toString tag read) rather than `instanceof ArrayBuffer`,
because instanceof is realm-local and the suite proved it: under jsdom a
Uint8Array from TextEncoder failed that check, and a client that failed it
would print "a message of an unknown type" at an operator instead of their
shell.

posture.ts turns the info answer into the ONE thing a control may be
(enabled, or disabled WITH A REASON) and never guesses. The order of its
checks is load-bearing and was found by a failing test: the handler's
pre-bridge refusal body carries no exists/has_tty at all, so it normalises
to exists:false — and reading that first filed "you are not signed in" as
"unknown sandbox", telling an operator their sandbox does not exist when the
truth is that the cockpit never looked.

session.svelte.ts probes BEFORE it connects, and that is the contract, not
an optimisation. WebSocket.onerror carries no status and no body by spec, so
a client that opened first could not name the reason for the failure it will
hit most often (the flag is off -> 503). Probing first means the refusal is
rendered with no socket at all, no upgrade and no ledger open. The
socket-died-without-a-frame path re-probes and, when the two answers
disagree, says so rather than picking one. Reconnection is deliberately
absent: a PTY is not an SSE read model, and reconnecting would write a
SECOND open entry against the same operator and target.
The UI half: a per-row attach cell, a by-id launcher, an xterm terminal and
the Sandbox Environment View as a modal (design Part 2 §4.1 screen 4 — "what
is this agent doing, and let me type into it").

Three decisions worth a reviewer's attention:

  - The rows carry the affordance, and the LAUNCHER exists because the
    cockpit has no sandbox-list read at all: info is a lookup BY ID, and the
    read model's census names four sources, none of which enumerates
    sandboxes. Rows-only would have shipped an unreachable feature. The
    launcher is the same posture as every other seam here — declare what is
    missing, make what IS possible possible — and the empty row renders a
    disabled button WITH the reason rather than no button, because "the
    registry is not read" and "this sandbox cannot be attached" are
    different facts.

  - The attach column is SECOND, not last, and that was decided by
    measurement: at 1440px the Fleet Overview's middle column is ~520px while
    the table wants ~640px, so a trailing column put the operator's one
    action off the right edge on the screen it was built for.

  - The dialog draws NO terminal until a session has actually opened. A
    refusal renders the status alone; an empty xterm below it would read as a
    shell that has not printed yet, which is the dead pane R6 forbids. It is
    portalled to <body> (the LD3.2b containing-block lesson), and its height
    is the terminal's height — with no terminal it shrinks to its five lines
    of status instead of wasting the screen.

One measurement worth recording, because nothing in review would have found
it: the visually-hidden reason sentence is position:absolute with no
positioned ancestor, so it escaped the table's overflow-x-auto scroll
container and grew the DOCUMENT's scrollWidth to 575px at a 390px viewport
— the exact sideways-scrolling page the 3.1a responsive net exists to catch,
and it caught it. Anchoring the cell (position: relative) keeps the sentence
inside the column the scroller clips.
76 unit tests across five new files, plus 9 more in the existing parity
guard (145 -> 221 total, 20 -> 25 files).

The protocol and session suites run against a hand-written socket, which is
the ONLY way most of these branches can be reached: a refused WebSocket
handshake reports no status and no body by spec, so the pre-upgrade refusal
path cannot be exercised against a real server at all — and it is the path
the flag-off server takes on every single attempt. Every honesty guarantee
is asserted at the layer that owns it, including the ones a passing suite
would otherwise hide: an invalid resize is WITHHELD rather than sent (the
backend closes the session over it), a binary frame that happens to spell
valid JSON is still data, a control frame never reaches the terminal, a late
frame from a replaced socket is ignored, and a fresh session resets every
counter so no previous sandbox's bytes are rendered as this one's.

The parity guard is extended to the attach contract and reads the Go source,
so the fixtures cannot drift behind a stale copy. It pins the Info json tag
set, the Term struct's ABSENCE of tags (the PascalCase trap), the
RefusalKind vocabulary, the status map, the no-TTY 200 override, the
registry wiring list, the DefaultTerm geometry, the close-reason vocabulary,
and the two refusal sentences character for character.

That guard is not ceremony. Two of the failures it and the e2e suite caught
were fixtures asserting payloads the backend never sends: a wiring array on
the disabled answer (the flag-off refusal is raised with an EMPTY wiring
argument, so the key is named in the reason instead) and a paraphrase of the
no-TTY sentence. Both were green in isolation and wrong about the wire.
HOMELAB-1683: test(cockpit-web): attach e2e — three postures, the dialog, 390px
Some checks are pending
Plan in progress...
CI Review / receiver-test (pull_request) Successful in 7s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m3s
CI Review / dispatcher-test (pull_request) Successful in 2m22s
CI Review / cockpit-test (pull_request) Successful in 2m39s
CI Review / cockpit-e2e (pull_request) Successful in 2m51s
126af06113
14 new Playwright specs (48 -> 62), against the real cockpit binary in dev
mode serving the real frontend build.

The split is forced by what this build can produce. LD_ATTACH_ENABLED is
false by design (the RBAC/CNP/arming delta is the operator's ratification
package), so the LIVE server answers 503 "attach disabled" on both
transports and records every attempt — the disabled posture is therefore
asserted completely end to end, including reading the response the page
actually received to check `disabled` and `audit_recorded` are true. An
attachable answer is impossible live, so the other two postures run with the
info payload fulfilled in the browser, from the same fixtures the Go tests
assert.

The most valuable spec is the one that follows the real refusal through the
frontend: a fulfilled "attachable" answer opens the dialog, the live server
then refuses the WebSocket upgrade, and the dialog has to land on an honest
end state — the disabled refusal, its policy line and "refused and recorded"
— with NO terminal element in the DOM. It is the R6 rule ("never a dead
black box") as an assertion rather than a promise, and it is also the path a
real operator hits whenever the registry moves under them.

The viewport guard from LD3.2b is parameterised rather than copied, so the
attach dialog — a second portal-to-body surface, mounted from inside a pane
nested under the sticky blurred header — gets the same measured
containing-block assertions at 1440 and 390, plus a structural check that
its parent really is <body>. Measured at the end of this branch: at 390px
the open dialog sits 675..836 inside an 844px viewport with its backdrop at
0..844, and the document scrollWidth is 390 against a 390px clientWidth.
The §5.2 vocabulary as a component pair, pure and unit-tested:

- `StateGlyph` draws one derived state: the mark (lucide strokes — the design's
  ⏸ and ✋ live in the emoji/symbol blocks and neither Inter nor the mono stack
  carries them, so the literal glyphs painted tofu in a real browser), the word,
  and the tone/texture the vocabulary declares.
- `DerivedStateRow` paints the ROW: §5.2's dim / amber / blue / hatch textures,
  the duration label ("parked 23m"), the gate a HELD row is held by (with a link
  when there is one), and the row's own sentence. Column-agnostic — the caller
  supplies its cells as `lead`/`trail` snippets — so the wave table, a future
  project wave list and a future firmware list share one visual language instead
  of three hand-rolled approximations of it.
- `unknown` is the DEFAULT state for both components: a caller that forgets to
  derive a state renders the hatched idle one, never green.
- `pending_rule` joins the vocabulary as the hardware-gated hole (§5.2's rule
  text, §6's ApexDirector hero card): a dashed mark, hatched, idle, never a
  tick. It is deliberately NOT in `DERIVED_STATE_ORDER` — §5.2's four
  registry-derivable states keep the legend unchanged.

The never-healthy rule is enforced three ways: a vocabulary test over every
state, a guard (`isHealthy`) that takes its table as an argument so the guard
itself can be tested against a MUTATED green-unknown table, and the dim moved
off the word onto the aria-hidden mark — an ancestor's opacity is invisible to
the responsive spec's contrast read, so dimming the whole chip was a silent
contrast regression hiding under a passing test.

`StatCell`'s value region gains `data-value`, so a spec can assert the
absence rule on the VALUE alone: an unmeasured cell's explanatory sentence may
legitimately contain digits ("not 41/41"), the value may not.
HOMELAB-1684: feat(cockpit-web): the telemetry panes (wave view, observed updates feed, hardened overview, project skeleton)
Some checks failed
Plan in progress...
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 19s
CI Review / dispatcher-test (pull_request) Successful in 2m20s
CI Review / cockpit-test (pull_request) Successful in 2m35s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m31s
CI Review / cockpit-e2e (pull_request) Failing after 2m48s
74f6343ac4
R12's scope, built: the derived-state visuals and the telemetry that exists
today. Everything else is an honest deferred state naming its source. `web/**`
only — no Go, no manifests, no flags.

**The wave view** (design §4.2). The sandbox table becomes a real Wave view: a
wave header, then one row per sandbox environment keyed by the id a REGISTRY
resolves — role, model tier, derived-state column, cost column, ticket, and
LD3.3's attach cell imported rather than duplicated. `sandboxCensus(sources)`
derives the row set's honesty from the read model's own source rows as a pure
function: the dispatcher decides (it is the row that would enumerate
sandboxes), the bus alone cannot key a row set, and an ABSENT row is unwired
rather than a licence to draw rows. While it is unwired — today's truth — the
view prints ONE honest hatched row naming the census it waits for: never fake
rows, and never an empty table (which reads as "no sandbox is running", a
measurement nobody made).

Absent means absent: model, cost and last beat print `—` with
`data-measured="false"`, never 0 and never $0.00 — the Costs source (OTel
`gen_ai.usage` + the OmniRoute gateway) is LD4's, and "the fleet spent nothing"
is an affirmative claim.

The honest row is rendered under the table rather than inside its body, decided
by looking at the rendered page rather than the mock: the table's min-content
is ~500px, so at 390px and in the Fleet Overview's 517px card a row of
em-dashes plus a half-visible clause would have hidden the one thing that row
exists to say. The header keeps the shape; the body is where a registry's rows
go.

**The updates feed.** §4.2's digest stream, carrying only events the cockpit
OBSERVED: read-model ticks the SSE stream actually delivered (a bounded local
ring, `untrack`ed so the effect cannot loop), Gates Queue rows that are waiting
on the operator, and this session's own dispositions including refusals. An
unobserved feed renders a hatched "nothing has been observed yet" sentence
rather than an empty list that reads as a quiet fleet; the digest half that
needs the bus ledger (LD1.5b) keeps its banner naming HOMELAB-1788.

**The overview counts.** `derived/fleet-counts.ts` derives the two counts the
screen may show, with the precedence stated once and each chosen to match what
the number MEANS: gates-needing-the-operator comes from the queue's own items
(a row needs you when it carries a decision package), class-E open comes from
the census field named for it. Both are null unless a read backed them — a
returned 0 stays a measurement, an absent one never becomes one.

**The deferred states.** The three homelab cells name their future source
(LD4/observability: the GitOps pins, kube-state-metrics, the resource budget
table) instead of being anonymous holes; the project views gain §6's skeleton
(waves · sandbox environments · releases strip · gate markers) with every slot
naming its source, and ApexDirector's hardware-gated hero demonstrates the
`pending_rule` hole where §6 puts it; R12's four deferred panes (Routines /
Alerts / Meeting / Costs) are recorded by name so their absence is a plan.

Tests: 221 → 277 vitest tests (25 → 32 files); e2e 62 → 74, with a new
`telemetry.spec.ts` asserting ONE honest wave row (never fabricated), the
absence rule on every `data-value` region, no `$` or standalone `0` in the
unwired cards, the never-healthy rule in the DOM, and the honest row's
sentence fully readable at 390px. The two existing specs change only where the
UI deliberately did: the `derived-state` testid now belongs to `StateGlyph`,
and the census-unwired sentence is single-sourced from
`derived/sandbox-census.ts`.
HOMELAB-1683: fix(cockpit-web): review blockers — byte accounting, refused-vs-unreachable, terminal mount coverage, guard bounds
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m1s
CI Review / dispatcher-test (pull_request) Successful in 2m28s
CI Review / cockpit-test (pull_request) Successful in 2m43s
CI Review / cockpit-e2e (pull_request) Successful in 2m54s
d581628a66
Four landing-review blockers from PR #584's dual fresh-context review, all in
lab-director/cockpit/web/**.

1. Byte accounting. `emit()` added every server→client frame to bytesOut while
   its caller also added the same bytes to bytesIn, and `sendInput` counted
   nothing — so the strip's pair was always equal (probe: 13 out + 3 in →
   in=13 out=13) and keystrokes were counted nowhere. Output is now counted once
   on the out side, input once on the in side (and only when actually sent), the
   status strip prints the close frame's authoritative totals when there is one
   and labels a tab-local count as such, and the tests assert a mixed in/out
   probe exactly.

2. 401/403 refusals. `isAttachAnswer` did not recognise the auth helper's
   Problem envelope, so an audited identity refusal threw and rendered as
   "unreachable · API error 401" with the server's sentence dropped. The envelope
   is now an answer at 401/403 (only those: a 5xx stays a failure), the sentence
   is rendered verbatim, and `audit_recorded`'s ABSENCE is a third value
   ('unknown') rather than a false claim about the ledger. WS-handshake refusals
   reach the same first-class refused state through the re-probe.

3. Terminal mount coverage. No test mounted the terminal in either direction
   (`showTerminal = false` left 221 unit + 14 e2e green). The e2e suite now
   serves a live session over a routed WebSocket: the dialog goes live, the
   terminal mounts, xterm renders the bytes the fixture sent, and the
   terminal-bearing dialog is geometry-checked at 390px.

4. Parity guard bounds. `MAX_TERM_DIMENSION` / `MAX_CONTROL_FRAME_BYTES` were
   pinned nowhere; lowering either in the Go left the suite green. The guard now
   parses both declarations (including `4 << 10`) out of the Go source and
   hard-fails on mismatch or on a bound it cannot read.

Verified: svelte-check 0 errors/0 warnings; vitest 26 files, 249 tests (guard 17);
build ok; Playwright 64 passed (attach.spec 14 → 16).
Merge remote-tracking branch 'origin/epic/LD3' into plane/HOMELAB-1684-derived-state
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 19s
CI Review / dispatcher-test (pull_request) Successful in 2m21s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m8s
CI Review / cockpit-test (pull_request) Successful in 2m37s
CI Review / cockpit-e2e (pull_request) Successful in 2m56s
e15f32ab74
HOMELAB-1684: fix(cockpit-web): never-healthy paint guard, deferred-pointer, absent numerals (review)
Some checks failed
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m33s
CI Review / dispatcher-test (pull_request) Successful in 2m44s
CI Review / cockpit-test (pull_request) Has been cancelled
CI Review / cockpit-e2e (pull_request) Has been cancelled
cbc11cecd0
HOMELAB-1683: feat(cockpit): live attach registry + go-live wiring (LD3.3)
Some checks failed
0/0 projects applied successfully.
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 9s
CI Review / dispatcher-test (pull_request) Successful in 3m48s
CI Review / cockpit-test (pull_request) Failing after 4m14s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 4m39s
CI Review / cockpit-e2e (pull_request) Successful in 5m58s
8d5eab2f8c
The attach bridge shipped in #579 behind LD_ATTACH_ENABLED with a registry seam
that refused everything, because the RBAC it needs was not ratified. The
ratification package §4/§5 (operator-ratified 2026-10-02) scopes exactly that
grant, so this slice builds the live implementation.

Registry (attach/kube_registry.go, new): read-only, three hops, every one the
controller's answer rather than a name this process composed.

  SandboxClaim <id>  extensions.agents.x-k8s.io/v1beta1 (by name: the sandbox id
                     IS the claim name the spawn arm returns)
    .status.sandbox.name -> Sandbox agents.x-k8s.io/v1beta1
      .status.selector   -> LIST pods by the controller's own selector
        container        <- the template container declaring stdin+tty (pi)
        class            <- .spec.podTemplate.labels[lab-director/class]
        ticket           <- lab-director/ticket (claim, then template)
        identity         <- LD_HERDR_LABEL on the harness container
        hasTTY           <- template stdin+tty AND the ratified class table

hasTTY is evidence AND policy: browser/firmware/meet-ai ship the same pi
container with a terminal and the package refuses those classes by class, so the
conservative answer is reported and the info read and the refusal cannot
disagree. claude-code is not in the harness table and its shipped template
declares no stdin/tty, so it refuses as unknown rather than silently inheriting
a shell.

Failures keep their shapes: an id with nothing attachable under it is
ErrUnknownSandbox with the registry's Why (unbound claim, no pod, torn down); a
Forbidden is NOT an unknown sandbox — it survives as an error naming the grant,
which the bridge renders as registry_unwired (503) so nobody is taught to stop
retrying a sandbox that is there.

Wire: the enabled path now builds the executor AND the live registry, once each,
from one client configuration (NewKubeClient, shared with the executor so the two
cannot read different configs). The disabled path constructs neither, asserted as
call counts (TestWireDisabledConstructsNoKubeClient, and the new
TestWireDisabledConstructsNoRegistry) — the inertness invariant is unchanged.

Config: LD_ATTACH_NAMESPACE (default agent-sandbox-system via
Attach.NamespaceOr). The registry refuses an empty namespace rather than
defaulting: a wrong namespace looks exactly like a fleet with no sandboxes.

Tests: envtest-free — the live registry is driven by fixture CRD objects through
the ClusterReader seam (claim -> Sandbox -> pod mapping, the per-class TTY table
taken from the prod templates, ticket/identity sources and their order, every
unknown-vs-cannot-look shape, two-pod determinism, offline construction, and one
end-to-end pass through Wire + Info). 192 -> 207 tests, -race green.

Go-live gap reported, not hidden: no object carries the Plane ticket today (the
spawn arm passes no labels to create_sandbox), so every attach refuses as
sandbox_without_ticket (409, audited) until the one-line spawn change lands in
dispatcher territory; same for LD_HERDR_LABEL.
HOMELAB-1684: style(cockpit-web): wrap the honest-state import, name the paint leg in the glyph spec (review)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 8s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 23s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m36s
CI Review / dispatcher-test (pull_request) Successful in 4m37s
CI Review / cockpit-test (pull_request) Successful in 4m37s
CI Review / cockpit-e2e (pull_request) Successful in 5m2s
9780a18b54
HOMELAB-1683: fix(cockpit): keep the pinned registry sentence byte-identical (web parity guard)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 21s
CI Review / cockpit-test (pull_request) Successful in 2m35s
CI Review / dispatcher-test (pull_request) Successful in 2m35s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m45s
CI Review / cockpit-e2e (pull_request) Successful in 3m11s
07105c058f
CI run #1752's cockpit-test failed on the FRONTEND step: the landed web parity
guard pins two Go strings character for character —
web/src/lib/tests/fixtures.test.ts ("keeps the registry wiring chips identical to
bridge.registryWiring"; "the sentence it ships IS the backend's") and
attach-launcher.test.ts (the chip text), both read out of attach/bridge.go.

`unwiredRegistryReason` and `registryWiring` are restored to their landed bytes.
They describe an EXPLICITLY refusing registry (NewUnwiredRegistry), which is
still a real disposition, and the comment above them now says so plus why they
must not be reworded from this territory. Rewording them would have required
editing two files under web/**, which this slice is bound not to touch.

The stale part is handled where it is reachable instead: the live registry's own
failure (a Forbidden, an unreachable API) now answers with liveRegistryReason /
liveRegistryWiring — "apply prod/manifests/lab-director/cockpit-rbac.yaml +
the kube-apiserver egress rule" — which no fixture pins and which names the fix an
operator actually has. Covered by
TestLiveRegistryFailureSpeaksTheLiveReasonNotThePreRatificationOne (asserts the
live sentence, the absence of the pre-ratification one, and the chip list).

Verified: Go vet + `go test -race -count=1 ./...` green (208 pass); the web suite
run locally against the whole cockpit module (as CI does, PLANE_GO_PATH depth
replicated) — 26 files / 249 tests pass, parity guard collected its 17 tests,
svelte-check 0 errors 0 warnings.
Merge pull request 'HOMELAB-1683: feat(cockpit): live attach registry + go-live wiring (LD3.3)' (#588) from plane/HOMELAB-1683-attach-live into epic/LD3
Some checks failed
CI Review / receiver-test (pull_request) Successful in 4s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 8s
Lint & Validate / shellcheck (pull_request) Successful in 5s
Lint & Validate / yaml-lint (pull_request) Successful in 14s
CI Review / alert-receiver-test (pull_request) Successful in 21s
CI Review / dispatcher-test (pull_request) Successful in 2m15s
CI Review / cockpit-test (pull_request) Successful in 2m48s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m52s
Lint & Validate / terraform-validate (pull_request) Successful in 2m58s
CI Review / cockpit-e2e (pull_request) Failing after 3m12s
0/0 projects applied successfully.
e9e7a6826b
Merge remote-tracking branch 'origin/live' into epic/LD3
All checks were successful
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 5s
Lint & Validate / shellcheck (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Lint & Validate / yaml-lint (pull_request) Successful in 12s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 11s
CI Review / dispatcher-test (pull_request) Successful in 2m10s
CI Review / cockpit-test (pull_request) Successful in 2m34s
Lint & Validate / terraform-validate (pull_request) Successful in 2m58s
CI Review / cockpit-e2e (pull_request) Successful in 3m0s
5a1e50a76a
Base-drift reconciliation for the LD3 epic PR (#589): live advanced 31 commits
(omniroute oidc-sync HOMELAB-1871, plane-ce vendor HOMELAB-1831, monitoring
alerts HOMELAB-1854/1835/1827, zitadel/atlantis HOMELAB-1865/1681b) while the
epic ran. The only overlapping file is .forgejo/workflows/ci.yaml: live added
helm-validate steps, the epic appended cockpit-test/cockpit-e2e. Disjoint
regions, additive in both directions, verified conflict-free by dry-run
(merge-tree + temp worktree) before this merge.
Author
Owner

Integration review — LD3 epic union diff

Fresh context, primary sources only (the union diff origin/live...origin/epic/LD3, the merged tree, the repo refs — not the implementer's summaries). Reviewed as ONE change set for integration defects; the individual slices were reviewed and landed separately.

Reviewed tree: b382e185591a45e9da7612376aaa81cc714bb907 — byte-identical to the head of this PR after the base-drift merge, and to the pre-push merge-tree dry run.

VERDICT: 0 blockers, 1 concern, 1 suggestion

Scope exactness — PASS

Delta vs live: 203 files, +41151 / −0. Exactly:

Path Status
lab-director/cockpit/** new (200 files)
.forgejo/workflows/cockpit-image-build.yaml new
.forgejo/workflows/ci.yaml modified, +112 / −0 vs merge-base
lab-director/.dockerignore new

0 deletions, 0 renames, 0 mode changes. No prod manifests, no unrelated files.

lab-director/dispatcher/** — untouched: tree blob bf48180b… identical to live, go.mod 9576f925… and go.sum 85113c7e… byte-identical to live. git diff origin/live...origin/epic/LD3 -- lab-director/dispatcher/ | wc -l → 0.

ci.yaml — conflict-free union, both job sets present

live also advanced ci.yaml (HOMELAB-1871 extended the existing helm-validate job). The epic's hunk appends cockpit-test / cockpit-e2e in a disjoint region. The merged file parses and carries:

helm-validate (with live's Test chart helper scripts + Render optional chart features), receiver-test, dispatcher-test, alert-receiver-test, cockpit-test, cockpit-e2e.

Additive in both directions: vs merge-base +112/−0; vs live +112/−0; vs epic +26/−0.

go.mod discipline — PASS

Cockpit direct requires all justified by imports (go-oidc/v3→auth, gorilla/websocket→attach, oauth2, apimachinery, client-go, local dispatcher via replace ⇒ ../dispatcher); go mod tidy -diff empty, go mod verify ok. Dispatcher module byte-identical to live.

Secrets — none

No BEGIN *PRIVATE KEY, no scheme://user:pass@ DSN, no ghp_/glpat-/xox/AKIA shapes, no high-entropy literals beyond digests/URLs/test names. ${{ secrets.X }} references and env-var names are not credentials; the only secret-named values are test fixtures. push stays restricted to live; the image push uses the scoped HARBOR_PUSH_* robot.

Landed blocker-fixes — all present in the union

Fix Location
CSP with SHA-256 hashes server/static.go:179 (derivation), server/middleware.go:155-169 (script-src 'self' …'sha256-…'), served at server/server.go:212
Service-worker registration wired web/svelte.config.js:30-32 (serviceWorker.register: true) + built shell registers /service-worker.js; e2e asserts SW active
Audit sentinel shape audit/audit.go:62 (SchemaSentinel = sentinel.AuditSchemaVersion); Entry.Principal/Scope are the sentinel package's own types; decode asserted at audit/audit_test.go:210
Never-healthy paint guard web/src/lib/honest-state.ts:204 (glyphPaint — texture outranks tone), used at state-glyph.svelte:78, asserted in state-glyph.test.ts + dashboard-pane.test.ts:119

Integration coherence — PASS

Fixture↔wire parity guard resolves ../../../../plane/plane.go at the module root and asserts the Go constants (17 tests collected ≥ the guard's floor). SSE vocabulary hello|heartbeat|readmodel matches sse/sse.go ↔ events.svelte.ts; API paths match the Go mux; SPA routes /, /login, /unauthorized are served by the fallback while /api, /auth are excluded. Image workflow CONTEXT=lab-director + DOCKERFILE=lab-director/cockpit/Dockerfile + lab-director/.dockerignore agree with the Dockerfile's COPY paths.

Local evidence on the reviewed tree: go vet clean, go test ./... ok, vitest 312 passed / 33 files, svelte-check 0 errors, SPA build ok, Playwright e2e 76 passed against the real binary and real SPA. Live CSP probe on the served shell returned the hashed policy with no 'unsafe-inline', hash independently reproduced from the served bytes.

[CONCERN] cockpit-image-build.yaml paths filter omits the dispatcher module

.forgejo/workflows/cockpit-image-build.yaml:58-68 — the push/pull_request paths filters list only lab-director/cockpit/**, lab-director/.dockerignore and the workflow file, but the cockpit image builds and links the dispatcher module (Dockerfile:82,89 do COPY dispatcher/go.mod dispatcher/go.sum and COPY dispatcher/ /src/dispatcher/), and the cockpit imports dispatcher/config, dispatcher/events, dispatcher/sentinel, dispatcher/spawn, dispatcher/watch.

Consequence: a dispatcher-only change merged to live never rebuilds or re-pushes the cockpit image, so the published cockpit image can carry a stale dispatcher — including the sentinel audit schema the cockpit reconciles against. Low severity (the image stays self-consistent for its own commit; this is staleness, not a broken build), and this is a mergeable concern, not a blocker: the epic's own delta is correct and the missing trigger is a follow-up.

[SUGGESTION] Go CI leg uses a floating toolchain

.forgejo/workflows/ci.yaml:106,144,204 run floating golang:1.24 while the image that ships this code pins golang:1.24.1-alpine@sha256:43c094ad… (lab-director/cockpit/Dockerfile:62). The Node steps in the same workflow deliberately pin the Dockerfile's exact digest, so the Go half is the inconsistent leg. Noted honestly: dispatcher-image-build.yaml's Go step has the same shape and predates this epic — a pre-existing repo convention, not a defect introduced here.


Base drift reconciled: this head is epic/LD3 merged with origin/live (5a1e50a), conflict-free per the dry run, and its tree hash is byte-identical to the tree reviewed above. Merging on green CI.

## Integration review — LD3 epic union diff Fresh context, primary sources only (the union diff `origin/live...origin/epic/LD3`, the merged tree, the repo refs — not the implementer's summaries). Reviewed as ONE change set for integration defects; the individual slices were reviewed and landed separately. **Reviewed tree:** `b382e185591a45e9da7612376aaa81cc714bb907` — byte-identical to the head of this PR after the base-drift merge, and to the pre-push `merge-tree` dry run. ### VERDICT: 0 blockers, 1 concern, 1 suggestion ### Scope exactness — PASS Delta vs `live`: **203 files, +41151 / −0**. Exactly: | Path | Status | |---|---| | `lab-director/cockpit/**` | new (200 files) | | `.forgejo/workflows/cockpit-image-build.yaml` | new | | `.forgejo/workflows/ci.yaml` | modified, **+112 / −0** vs merge-base | | `lab-director/.dockerignore` | new | `0 deletions`, `0 renames`, `0 mode changes`. No prod manifests, no unrelated files. `lab-director/dispatcher/**` — **untouched**: tree blob `bf48180b…` identical to `live`, `go.mod` `9576f925…` and `go.sum` `85113c7e…` byte-identical to `live`. `git diff origin/live...origin/epic/LD3 -- lab-director/dispatcher/ | wc -l` → `0`. ### ci.yaml — conflict-free union, both job sets present `live` also advanced `ci.yaml` (HOMELAB-1871 extended the existing `helm-validate` job). The epic's hunk appends `cockpit-test` / `cockpit-e2e` in a disjoint region. The merged file parses and carries: `helm-validate` (with live's `Test chart helper scripts` + `Render optional chart features`), `receiver-test`, `dispatcher-test`, `alert-receiver-test`, `cockpit-test`, `cockpit-e2e`. Additive in both directions: vs merge-base `+112/−0`; vs `live` `+112/−0`; vs epic `+26/−0`. ### go.mod discipline — PASS Cockpit direct requires all justified by imports (`go-oidc/v3`→auth, `gorilla/websocket`→attach, `oauth2`, `apimachinery`, `client-go`, local `dispatcher` via `replace ⇒ ../dispatcher`); `go mod tidy -diff` empty, `go mod verify` ok. Dispatcher module byte-identical to `live`. ### Secrets — none No `BEGIN *PRIVATE KEY`, no `scheme://user:pass@` DSN, no `ghp_/glpat-/xox/AKIA` shapes, no high-entropy literals beyond digests/URLs/test names. `${{ secrets.X }}` references and env-var names are not credentials; the only `secret`-named values are test fixtures. `push` stays restricted to `live`; the image push uses the scoped `HARBOR_PUSH_*` robot. ### Landed blocker-fixes — all present in the union | Fix | Location | |---|---| | CSP with SHA-256 hashes | `server/static.go:179` (derivation), `server/middleware.go:155-169` (`script-src 'self' …'sha256-…'`), served at `server/server.go:212` | | Service-worker registration wired | `web/svelte.config.js:30-32` (`serviceWorker.register: true`) + built shell registers `/service-worker.js`; e2e asserts SW active | | Audit sentinel shape | `audit/audit.go:62` (`SchemaSentinel = sentinel.AuditSchemaVersion`); `Entry.Principal/Scope` are the sentinel package's own types; decode asserted at `audit/audit_test.go:210` | | Never-healthy paint guard | `web/src/lib/honest-state.ts:204` (`glyphPaint` — texture outranks tone), used at `state-glyph.svelte:78`, asserted in `state-glyph.test.ts` + `dashboard-pane.test.ts:119` | ### Integration coherence — PASS Fixture↔wire parity guard resolves `../../../../plane/plane.go` at the module root and asserts the Go constants (17 tests collected ≥ the guard's floor). SSE vocabulary `hello|heartbeat|readmodel` matches `sse/sse.go` ↔ `events.svelte.ts`; API paths match the Go mux; SPA routes `/`, `/login`, `/unauthorized` are served by the fallback while `/api`, `/auth` are excluded. Image workflow `CONTEXT=lab-director` + `DOCKERFILE=lab-director/cockpit/Dockerfile` + `lab-director/.dockerignore` agree with the Dockerfile's `COPY` paths. Local evidence on the reviewed tree: `go vet` clean, `go test ./...` ok, vitest **312 passed / 33 files**, `svelte-check` 0 errors, SPA build ok, Playwright e2e **76 passed** against the real binary and real SPA. Live CSP probe on the served shell returned the hashed policy with no `'unsafe-inline'`, hash independently reproduced from the served bytes. ### [CONCERN] cockpit-image-build.yaml paths filter omits the dispatcher module `.forgejo/workflows/cockpit-image-build.yaml:58-68` — the `push`/`pull_request` `paths` filters list only `lab-director/cockpit/**`, `lab-director/.dockerignore` and the workflow file, but the cockpit image **builds and links** the dispatcher module (`Dockerfile:82,89` do `COPY dispatcher/go.mod dispatcher/go.sum` and `COPY dispatcher/ /src/dispatcher/`), and the cockpit imports `dispatcher/config`, `dispatcher/events`, `dispatcher/sentinel`, `dispatcher/spawn`, `dispatcher/watch`. Consequence: a dispatcher-only change merged to `live` never rebuilds or re-pushes the cockpit image, so the published cockpit image can carry a stale dispatcher — including the `sentinel` audit schema the cockpit reconciles against. Low severity (the image stays self-consistent for its own commit; this is staleness, not a broken build), and this is a **mergeable concern**, not a blocker: the epic's own delta is correct and the missing trigger is a follow-up. ### [SUGGESTION] Go CI leg uses a floating toolchain `.forgejo/workflows/ci.yaml:106,144,204` run floating `golang:1.24` while the image that ships this code pins `golang:1.24.1-alpine@sha256:43c094ad…` (`lab-director/cockpit/Dockerfile:62`). The Node steps in the same workflow deliberately pin the Dockerfile's exact digest, so the Go half is the inconsistent leg. Noted honestly: `dispatcher-image-build.yaml`'s Go step has the same shape and predates this epic — a pre-existing repo convention, not a defect introduced here. --- Base drift reconciled: this head is `epic/LD3` merged with `origin/live` (`5a1e50a`), conflict-free per the dry run, and its tree hash is byte-identical to the tree reviewed above. Merging on green CI.
aaron merged commit 5215bcb05e into live 2026-10-02 21:39:23 +00:00
Author
Owner

Merged — LD3 epic landed on live

live @ 5215bcb05e — Merge pull request 'LD3 EPIC: LD Cockpit — observer app (attach, telemetry panes, gates queue, image build)' (#589) from epic/LD3 into live

parents: 6589f7e (previous live)   5a1e50a (epic head)

True merge commit, not a squash — spot-checked slice commits (e9e7a68, 009c76b, 8d5eab2, 74f6343, 4348525, b0d4eea, 65e357f, bb99fd9) are all ancestors of live. git diff origin/live 5a1e50a is empty, i.e. the merged tree is exactly the tree reviewed above.

Landing lock: review verdict 2026-10-02T21:33:46Z post-dates head commit 2026-10-02T21:31:23Z.

CI on the merged head — green

Run Workflow Result
#1761 ci.yaml pass — cockpit-e2e success 3m0s, cockpit-test 2m34s, dispatcher-test 2m10s, helm-validate 8s, receiver-test 5s, alert-receiver-test 20s
#1762 cockpit-image-build.yaml pass
#1763 lint.yaml pass

The telemetry.spec.ts:275 failure seen on the pre-drift head (#1758) passed here — confirming it was flakiness (that spec has no readiness wait before its page.evaluate), not a defect in this change set. It also passed 8/8 local runs and in the full 76-test local suite on the identical tree.

GitOps continuation

Exactly one downstream workflow fired (matching the repo's path filters):

  • Run #1764 Build Lab Director Cockpit Image @ 5215bcb — pass (1m53s).
  • Harbor lab-director/cockpit:
    • 5215bcb → sha256:ca76fd2e0f9c84c58f7d943530fbc5f25e3da877a751cdc3a851bd53b1e61b92
    • latest moved to the same digest (built from refs/heads/live, so the promotion rule applied). Previous latest/65e357f was sha256:a248f426….
  • dispatcher-image-build.yaml correctly did not fire — lab-director/dispatcher/** is untouched.
  • No rollout pin to move: the prod repo still has no cockpit manifest or digest pin (only manifests/lab-director/namespace.yaml). The LD3.1b deploy slice owns that; until it lands, pin sha256:ca76fd2e….

Open follow-ups (non-blocking)

  1. cockpit-image-build.yaml:58-68 — add lab-director/dispatcher/** to the paths filters, or a dispatcher-only change on live leaves the published cockpit image carrying a stale shared module (incl. the sentinel audit schema).
  2. ci.yaml Go steps run floating golang:1.24 while the shipped image pins golang:1.24.1-alpine@sha256:43c094ad…; the Node steps already pin.

Full landing log: .rpiv/artifacts/build/ld3-epic-pr-landing.md.

## Merged — LD3 epic landed on `live` **`live @ 5215bcb05e`** — `Merge pull request 'LD3 EPIC: LD Cockpit — observer app (attach, telemetry panes, gates queue, image build)' (#589) from epic/LD3 into live` ``` parents: 6589f7e (previous live) 5a1e50a (epic head) ``` True **merge commit**, not a squash — spot-checked slice commits (`e9e7a68`, `009c76b`, `8d5eab2`, `74f6343`, `4348525`, `b0d4eea`, `65e357f`, `bb99fd9`) are all ancestors of `live`. `git diff origin/live 5a1e50a` is empty, i.e. the merged tree is exactly the tree reviewed above. Landing lock: `review verdict 2026-10-02T21:33:46Z post-dates head commit 2026-10-02T21:31:23Z`. ### CI on the merged head — green | Run | Workflow | Result | |---|---|---| | #1761 | `ci.yaml` | **pass** — `cockpit-e2e` **success 3m0s**, `cockpit-test` 2m34s, `dispatcher-test` 2m10s, `helm-validate` 8s, `receiver-test` 5s, `alert-receiver-test` 20s | | #1762 | `cockpit-image-build.yaml` | **pass** | | #1763 | `lint.yaml` | **pass** | The `telemetry.spec.ts:275` failure seen on the pre-drift head (#1758) **passed here** — confirming it was flakiness (that spec has no readiness wait before its `page.evaluate`), not a defect in this change set. It also passed 8/8 local runs and in the full 76-test local suite on the identical tree. ### GitOps continuation Exactly one downstream workflow fired (matching the repo's path filters): - **Run #1764** `Build Lab Director Cockpit Image` @ `5215bcb` — **pass** (1m53s). - Harbor `lab-director/cockpit`: - `5215bcb` → `sha256:ca76fd2e0f9c84c58f7d943530fbc5f25e3da877a751cdc3a851bd53b1e61b92` - **`latest` moved to the same digest** (built from `refs/heads/live`, so the promotion rule applied). Previous `latest`/`65e357f` was `sha256:a248f426…`. - `dispatcher-image-build.yaml` correctly did **not** fire — `lab-director/dispatcher/**` is untouched. - **No rollout pin to move:** the prod repo still has no cockpit manifest or digest pin (only `manifests/lab-director/namespace.yaml`). The LD3.1b deploy slice owns that; until it lands, pin `sha256:ca76fd2e…`. ### Open follow-ups (non-blocking) 1. `cockpit-image-build.yaml:58-68` — add `lab-director/dispatcher/**` to the `paths` filters, or a dispatcher-only change on `live` leaves the published cockpit image carrying a stale shared module (incl. the `sentinel` audit schema). 2. `ci.yaml` Go steps run floating `golang:1.24` while the shipped image pins `golang:1.24.1-alpine@sha256:43c094ad…`; the Node steps already pin. Full landing log: `.rpiv/artifacts/build/ld3-epic-pr-landing.md`.
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!589
No description provided.