HOMELAB-1859: LD4 slot 6 — sandbox class egress + result-return path (the result gateway) #597

Merged
aaron merged 8 commits from plane/HOMELAB-1859-gateway into epic/LD4 2026-10-03 04:30:02 +00:00
Owner

Implements the ratified design (.rpiv/artifacts/epic-ld4/design-1859-sandbox-egress.md) and the orchestrator adjudication on HOMELAB-1859: the result gateway. No sandbox class gains Plane egress; the product returns through one authenticated hop, and the lane watcher ends a lane on the bundle instead of the 4h TTL. Prod manifests are a separate PR.

What is built, against the brief's map

Brief item Where Notes
gateway/api.go — Submission{Verb, Comment, Drafts}, no project/state/target field gateway/api.go strict decode: an unknown field is ErrUnknownField (400), not ignored. Size caps. Verb allowlist + role charter (roundtable comments; architect comments+drafts; every other role writes nothing). BacklogState is a constant, not a field
gateway/nonce.go — HMAC over role/ticket/lane/expiry gateway/nonce.go expiry = min(claim lease deadline, now+15m). Deviation (documented in the file): the brief's shorthand is base64url(HMAC(...)), which a verifier cannot recompute the claims from; the shipped token is <base64url(payload)>.<base64url(mac)> over the same fields. Constant-time compare, Bearer-only
gateway/server.go — POST /v1/submit + health gateway/server.go request order: nonce → asserted-target check → strict decode → shape → charter → replay ledger → per-role token → write → receipt. 401/400/403/409/503 each named
gateway/plane_writer.go — CreateWorkItem(state=Backlog) gateway/plane_writer.go the gateway's OWN narrow client (Comment + CreateItem only; Backlog resolved by name or the backlog group). Deliberately not claims.APIWriter: the dispatcher owns state/leases, the gateway owns products — stated in the README
per-role PAT resolution from config refs gateway/server.go (roleToken), cmd/ld-result-gateway/main.go registry TokenRefFor → mounted Secret map. The token map is built FROM the registry, so adding a role's credential is a registry entry + a Secret key
cmd/ld-result-gateway/main.go cmd/ld-result-gateway/ + Dockerfile --check/--version, graceful shutdown, /healthz + /readyz. The binary rides in the dispatcher image (same HOMELAB-1688 precedent as cmd/routines: one module, one reviewed digest)
spawn submit-token upload (R2, landed BEFORE the CNP step) spawn/spawn.go, spawn/evidence.go, spawn/errors.go .sandbox-init/submit-token, OUTSIDE the pinned package dir, then chmod 600 through the sandbox's own shell. First out-of-package driver file; the git token (§2.6.4's other .sandbox-init/ file) is still unshipped and will use the same helper
resultBundleWatcher (lane ends on the bundle, not the TTL) cmd/dispatcher/resultwatch.go, wired in app.go probes .sandbox-state/agent/result.json with the sandbox's own test -f (a verified shape; download_file cannot distinguish absence from a broken transport, and the MCP file_exists response shape is not verified against the pinned server — guessing it would be fabrication). teardown/* unchanged; the old TTL ending is the fallback
LaneOutcome.Contract from the compiled ticket section (OQ-7(ii)) cmd/dispatcher/resultwatch.go (contractFromPackage) a fenced ```ld-result-contract block of name: sha256:<hex>. Malformed → refused BEFORE the claim; absent → the honest empty contract
identity/ roundtable entries + ActiveRolesInV1 + pinned test identity/example-registry.yaml, model.go, tests 4 surfaces, refs at kv/app-credentials (OQ-2(a)); the architect's plane ref reconciled to the same object. Pinned counts moved (9 roles / 39 entries / 10 chat accounts)
identity/surface_plane.go unchanged TokenRefFor was already the seam; this is its first real consumer

Verify lines, mechanized

  1. A class's only Plane write path is scoped (AC re-read per the adjudication): gateway/server_test.go names every refusal and asserts zero Plane calls — no nonce, forged nonce, expired nonce, wrong-ticket assertion, unknown field (state/project/ticket/assignee), unchartered role, replay 409, unwired-ref 503. gateway/plane_writer_test.go pins the wire shapes (one-field comment body; name/description_html/state only, with the resolved Backlog UUID). The live probe half (sandbox → Plane 000, sandbox → gateway 200/409, cilium-dbg policy trace) runs at activation; the class rule is in the prod PR.
  2. A sandbox product returns without ReasonNoResult: cmd/dispatcher/resultwatch_test.go drives the whole loop over the fixtures through a real gateway on httptest with the real mint/verify path — the record lands, the bundle ends the lane, disposition=completed, evidence.verified=true, no no_result. The regression is in the same file: a lane that submits nothing still ends no_result + failed with the nonce delivered, and a bundle declaring its own contract is refused.
  3. Roundtable record + architect-drafted ticket: same file, both shapes end-to-end (the record as a comment attributed to the roundtable's token; the drafts as Backlog items attributed to the architect's), plus RoleForLane being closed so A–E class lanes get no nonce and keep exactly their current reach. Live landing awaits activation (step 6) — see below.

go vet ./... and go test -race ./... are green for lab-director/dispatcher.

Deviations from the brief (all stated in-code)

  1. Nonce encoding: <base64url(payload)>.<base64url(mac)> instead of the brief's MAC-only shorthand — a verifier needs the claims (gateway/nonce.go header).
  2. Replay is stronger than documented at 1 replica: the ledger reservation is taken before the write and released only on failure, so two concurrent submissions with the same nonce cannot both write. The OQ-4 TOCTOU applies from N replicas (the Deployment runs 1).
  3. A path-form asserted ticket (/v1/submit/{ticket}) was added so a caller CAN state the ticket it believes it is writing to; the statement is checked against the nonce and never honored. The type itself still has no target field.
  4. product-receipt presence is enforced by the lane watcher, not by the ticket contract: a runtime-produced artifact cannot be digest-pinned before the run, so teardown's declared-vs-observed math plus the watcher's ticket/role/lane/body_sha256 checks carry it. (An empty contract is still honest, not a pass.)
  5. Nonce lifetime = 15 minutes (min(claim deadline, now+15m), as specified). A run that takes longer than that gets a 401 and its lane closes ReasonNoResult — loud and named. Re-minting on the heartbeat is the follow-up; a longer-lived nonce would silently widen the boundary. Flagged for the reviewer.
  6. The gateway binary is added to the dispatcher image, so the gateway Deployment's digest readback is an activation step (prod PR header carries the recipe).

What activation still needs (steps 1 and 6, operator)

  1. Seed kv/app-credentials: plane_architect_api_token, plane_roundtable_api_token (step 1, the two Plane service users per README §As-built) and ld_submit_hmac_key.
  2. Read back the image digest after this merges to live (the image workflow triggers on live, not on this epic branch) and pin it in the gateway Deployment.
  3. Uncomment the two lane keys in prod/manifests/lab-director/dispatcher-config.yaml and re-apply (step 6) — that is what turns the lanes on. Until then the whole path is inert: no lane resolves, no nonce is minted, and no A–E lane changes behaviour at all.
  4. Re-sync the prod registry copy already done in this PR (it was stale: intake had never reached the mount the dispatcher reads) — the gateway mounts that same key.

Related: HOMELAB-1685 (intake/architect drafting), HOMELAB-1686 (roundtable record), HOMELAB-1784/1786 (the CNP convention and the Gateway-VIP hole this design does not depend on).

Implements the ratified design (`.rpiv/artifacts/epic-ld4/design-1859-sandbox-egress.md`) and the orchestrator adjudication on HOMELAB-1859: **the result gateway**. No sandbox class gains Plane egress; the product returns through one authenticated hop, and the lane watcher ends a lane on the bundle instead of the 4h TTL. Prod manifests are a separate PR. ## What is built, against the brief's map | Brief item | Where | Notes | |---|---|---| | `gateway/api.go` — `Submission{Verb, Comment, Drafts}`, no project/state/target field | `gateway/api.go` | strict decode: an unknown field is `ErrUnknownField` (400), not ignored. Size caps. Verb allowlist + role charter (roundtable comments; architect comments+drafts; every other role writes nothing). `BacklogState` is a constant, not a field | | `gateway/nonce.go` — HMAC over role/ticket/lane/expiry | `gateway/nonce.go` | expiry = `min(claim lease deadline, now+15m)`. **Deviation (documented in the file):** the brief's shorthand is `base64url(HMAC(...))`, which a verifier cannot recompute the claims from; the shipped token is `<base64url(payload)>.<base64url(mac)>` over the same fields. Constant-time compare, `Bearer`-only | | `gateway/server.go` — `POST /v1/submit` + health | `gateway/server.go` | request order: nonce → asserted-target check → strict decode → shape → charter → replay ledger → per-role token → write → receipt. 401/400/403/409/503 each named | | `gateway/plane_writer.go` — `CreateWorkItem(state=Backlog)` | `gateway/plane_writer.go` | the gateway's OWN narrow client (Comment + CreateItem only; Backlog resolved by name or the `backlog` group). Deliberately not `claims.APIWriter`: the dispatcher owns state/leases, the gateway owns products — stated in the README | | per-role PAT resolution from config refs | `gateway/server.go` (`roleToken`), `cmd/ld-result-gateway/main.go` | registry `TokenRefFor` → mounted Secret map. The token map is built FROM the registry, so adding a role's credential is a registry entry + a Secret key | | `cmd/ld-result-gateway/main.go` | `cmd/ld-result-gateway/` + `Dockerfile` | `--check`/`--version`, graceful shutdown, `/healthz` + `/readyz`. The binary rides in the **dispatcher image** (same HOMELAB-1688 precedent as `cmd/routines`: one module, one reviewed digest) | | spawn submit-token upload (R2, landed BEFORE the CNP step) | `spawn/spawn.go`, `spawn/evidence.go`, `spawn/errors.go` | `.sandbox-init/submit-token`, OUTSIDE the pinned package dir, then `chmod 600` through the sandbox's own shell. First out-of-package driver file; the git token (§2.6.4's other `.sandbox-init/` file) is still unshipped and will use the same helper | | `resultBundleWatcher` (lane ends on the bundle, not the TTL) | `cmd/dispatcher/resultwatch.go`, wired in `app.go` | probes `.sandbox-state/agent/result.json` with the sandbox's own `test -f` (a *verified* shape; `download_file` cannot distinguish absence from a broken transport, and the MCP `file_exists` response shape is **not** verified against the pinned server — guessing it would be fabrication). `teardown/*` unchanged; the old TTL ending is the fallback | | `LaneOutcome.Contract` from the compiled ticket section (OQ-7(ii)) | `cmd/dispatcher/resultwatch.go` (`contractFromPackage`) | a fenced ` ```ld-result-contract ` block of `name: sha256:<hex>`. Malformed → refused BEFORE the claim; absent → the honest empty contract | | `identity/` roundtable entries + `ActiveRolesInV1` + pinned test | `identity/example-registry.yaml`, `model.go`, tests | 4 surfaces, refs at `kv/app-credentials` (OQ-2(a)); the architect's plane ref reconciled to the same object. Pinned counts moved (9 roles / 39 entries / 10 chat accounts) | | `identity/surface_plane.go` | unchanged | `TokenRefFor` was already the seam; this is its first real consumer | ## Verify lines, mechanized 1. **A class's only Plane write path is scoped** (AC re-read per the adjudication): `gateway/server_test.go` names every refusal and asserts **zero Plane calls** — no nonce, forged nonce, expired nonce, wrong-ticket assertion, unknown field (`state`/`project`/`ticket`/`assignee`), unchartered role, replay 409, unwired-ref 503. `gateway/plane_writer_test.go` pins the wire shapes (one-field comment body; `name`/`description_html`/`state` only, with the resolved Backlog UUID). The **live probe** half (sandbox → Plane `000`, sandbox → gateway `200`/`409`, `cilium-dbg policy trace`) runs at activation; the class rule is in the prod PR. 2. **A sandbox product returns without ReasonNoResult**: `cmd/dispatcher/resultwatch_test.go` drives the whole loop over the fixtures through a **real gateway** on `httptest` with the real mint/verify path — the record lands, the bundle ends the lane, `disposition=completed`, `evidence.verified=true`, no `no_result`. The regression is in the same file: a lane that submits nothing still ends `no_result` + `failed` **with the nonce delivered**, and a bundle declaring its own contract is refused. 3. **Roundtable record + architect-drafted ticket**: same file, both shapes end-to-end (the record as a comment attributed to the roundtable's token; the drafts as Backlog items attributed to the architect's), plus `RoleForLane` being closed so A–E class lanes get no nonce and keep exactly their current reach. **Live landing awaits activation (step 6)** — see below. `go vet ./...` and `go test -race ./...` are green for `lab-director/dispatcher`. ## Deviations from the brief (all stated in-code) 1. **Nonce encoding**: `<base64url(payload)>.<base64url(mac)>` instead of the brief's MAC-only shorthand — a verifier needs the claims (`gateway/nonce.go` header). 2. **Replay is stronger than documented at 1 replica**: the ledger reservation is taken before the write and released only on failure, so two concurrent submissions with the same nonce cannot both write. The OQ-4 TOCTOU applies from N replicas (the Deployment runs 1). 3. **A path-form asserted ticket** (`/v1/submit/{ticket}`) was added so a caller CAN state the ticket it believes it is writing to; the statement is checked against the nonce and never honored. The type itself still has no target field. 4. **`product-receipt` presence is enforced by the lane watcher**, not by the ticket contract: a runtime-produced artifact cannot be digest-pinned before the run, so teardown's declared-vs-observed math plus the watcher's ticket/role/lane/`body_sha256` checks carry it. (An empty contract is still honest, not a pass.) 5. **Nonce lifetime = 15 minutes** (`min(claim deadline, now+15m)`, as specified). A run that takes longer than that gets a 401 and its lane closes `ReasonNoResult` — loud and named. Re-minting on the heartbeat is the follow-up; a longer-lived nonce would silently widen the boundary. **Flagged for the reviewer.** 6. The gateway binary is added to the **dispatcher image**, so the gateway Deployment's digest readback is an activation step (prod PR header carries the recipe). ## What activation still needs (steps 1 and 6, operator) 1. **Seed** `kv/app-credentials`: `plane_architect_api_token`, `plane_roundtable_api_token` (step 1, the two Plane service users per README §As-built) and `ld_submit_hmac_key`. 2. **Read back the image digest** after this merges to `live` (the image workflow triggers on `live`, not on this epic branch) and pin it in the gateway Deployment. 3. **Uncomment the two lane keys** in `prod/manifests/lab-director/dispatcher-config.yaml` and re-apply (step 6) — that is what turns the lanes on. Until then the whole path is inert: no lane resolves, no nonce is minted, and no A–E lane changes behaviour at all. 4. Re-sync the prod registry copy **already done in this PR** (it was stale: `intake` had never reached the mount the dispatcher reads) — the gateway mounts that same key. Related: HOMELAB-1685 (intake/architect drafting), HOMELAB-1686 (roundtable record), HOMELAB-1784/1786 (the CNP convention and the Gateway-VIP hole this design does not depend on).
OQ-3's activation: the roundtable is the role whose Plane user must own the
meeting-record comment, so the record is attributed to the role that produced it
rather than to the architect (contract §8).

- example-registry.yaml: roundtable binds all four canonical surfaces with
  tokenRefs (an ACTIVE role must bind every surface, registry.go), and joins
  activeRoles. Its plane ref points at kv/app-credentials — the same object the
  gateway's plane-role-writers ExternalSecret seeds (OQ-2(a)).
- example-registry.yaml: the architect's plane ref is reconciled to the SAME
  kv/app-credentials object (OQ-2(a): the only agent-seedable credential path per
  HOMELAB-1711's AppRole ruling). One path, so the gateway's per-role resolution
  and the mounted secret cannot disagree.
- model.go: ActiveRolesInV1() gains roundtable (appended last; the list order is
  pinned against the example's declaration order).
- registry_test.go / surfaces_test.go: the pinned counts move (9 roles, 39
  entries, 10 chat accounts) and the roundtable's refs/activity are asserted.

Minimal and localized on purpose: three sibling builders are adding routines
registry entries in parallel branches, so this hunk touches only the identity
artifact and its two pinned tests.
The one authenticated hop by which a sandbox's product reaches Plane, plus the
process that serves it.

- gateway/api.go: Submission{Verb, Comment, Drafts} — deliberately NO project,
  state, assignee or target field (the ticket is the nonce's, a draft's state is
  the Backlog constant the watch never publishes). Strict JSON decoding refuses
  an unknown field at the boundary, so no request can smuggle a target or a
  state. Verb allowlist + role charter: the roundtable comments, the architect
  drafts, every other role writes nothing. Size caps. Receipt shape.
- gateway/nonce.go: HMAC-SHA256 over role||ticket||lane||expiry, minted by the
  dispatcher (min(claim deadline, now+15m)), presented as
  "Authorization: Bearer <nonce>", verified statelessly in constant time. The
  token is <base64url(payload)>.<base64url(mac)> — the brief's shorthand writes
  the MAC alone, which a verifier cannot recompute the claims from; the file
  documents the deviation. Fingerprint-for-ledger, Bearer-only scheme.
- gateway/plane_writer.go: the gateway's own narrow Plane client (Comment,
  CreateItem which resolves the project's Backlog state by name or group; no
  transition, no edit, no read on the hot path). Separate from claims.APIWriter
  on purpose: the dispatcher owns state/leases with one key, the gateway owns
  product writes with per-role keys.
- gateway/server.go: the request path (nonce → target assertion → strict decode
  → shape → charter → replay ledger → per-role token → write → receipt). The
  replay ledger is a real single-use guarantee for ONE replica (reservation
  taken before the write, released on failure so an outage does not burn the
  nonce) and the documented TOCTOU beyond that (OQ-4). A path-form asserted
  ticket must equal the nonce's, so a nonce cannot be retargeted.
- cmd/ld-result-gateway: env config (LD_SUBMIT_HMAC_KEY, PLANE_*, the
  registry-derived per-role LD_PLANE_TOKEN_<ROLE>), --check/--version, graceful
  shutdown, /healthz + /readyz. The token map is built FROM the registry's
  tokenRefs, so adding a role's credential is a registry entry plus a Secret key.
- Dockerfile: the gateway binary rides in the dispatcher image (the same
  HOMELAB-1688 precedent as routines): one module, one reviewed digest.

Tests (no sockets): every Verify-line-1 refusal is a named fixture test asserting
ZERO Plane calls (no nonce, forged nonce, expired nonce, wrong-ticket assertion,
unknown field, unchartered role), plus attribution, Backlog-only creation, replay
409, 503-with-the-missing-ref and nonce-release on outage.
Design brief R2, landed BEFORE the CNP step so the class egress rule is never the
first thing that depends on an unshipped upload path.

- spawn.Spec.SubmitToken: the driver-minted dispatch nonce. Nil means no gateway
  is wired, and the arm writes nothing extra — a lane that stays inert beats a
  lane holding a half-configured credential.
- The arm writes .sandbox-init/submit-token (constant path, outside ContextDir:
  the package directory is content-addressed and pin-verified, so a nonce inside
  it would break the pin) and then chmod 600 through the sandbox's own shell, the
  contract's recipe for the sandbox-key file (the MCP upload tool cannot carry a
  mode). This is the arm's FIRST auxiliary upload: until now it wrote only
  package sections. The git token (contract §2.6.4's other .sandbox-init file) is
  still specified-but-unshipped, and the same helper is what will land it.
- checkSubmitToken refuses a malformed nonce at preflight, before any MCP call
  (whitespace, non-printable bytes, oversize) — ErrInvalidSubmitToken. The arm
  does not parse the nonce: it holds no key, and a driver that could interpret
  its own nonce could mint a different one.
- SpawnEvidence gains SubmitTokenPath and SubmitTokenDigest (sha256 of the
  delivered nonce, which is the gateway's ledger fingerprint). The token VALUE is
  never recorded, and the log one-liner carries neither the value nor its digest.

Tests: byte-exact upload at the contract path, exactly one write and it is
outside the package dir, the chmod argv, evidence path+digest and the absence of
the token from the log line; optional-token; the five malformed classes refused
with ZERO MCP calls; and a failed nonce upload aborting+deleting the sandbox
(stage=write-submit-token).
The result-return path: a lane now ends when its run's bundle lands, and Plane
gets written as the ROLE through the gateway.

- cmd/dispatcher/resultwatch.go: the resultBundleWatcher. It probes
  .sandbox-state/agent/result.json with the sandbox's own `test -f` through
  execute_command — a shape the contract verifies (a non-zero exit is DATA, not a
  tool error), unlike download_file (where absence and a broken transport look
  alike) and unlike the MCP file_exists tool, whose response shape is not verified
  against the pinned server. On presence it reads, strictly decodes into
  teardown.Result, and returns LaneOutcome{Result, Contract}: teardown is
  unchanged. On the sandbox TTL with nothing, the OLD ending stands —
  ReasonNoResultSource, a FAILED run. A bundle that declares its OWN contract is
  refused at the decoder boundary, and a gateway-wired lane whose bundle has no
  product-receipt (or a receipt from another ticket/role/lane) is refused.
- contractFromPackage: the ResultContract comes from the compiled package's
  ticket-and-acceptance-criteria section (OQ-7(ii)) — a fenced ld-result-contract
  block of name: sha256:<hex> entries. A malformed declaration is a refusal
  BEFORE the claim; no declaration is the honest empty contract.
- pipeline.go: the lane's dispatch nonce is minted after the claim (expiry =
  min(the claim's lease deadline, now+15m)) and handed to spawn; the watcher
  receives the lane's expectations (ticket UUID, role, whether a nonce was
  delivered, the ticket's contract) through the optional laneExpecter interface,
  so every existing watcher and fixture keeps working unchanged. An A-E class lane
  gets no nonce and keeps exactly the reach it had before.
- gateway/lanes.go: the v1 lane→role binding (roundtable→roundtable,
  direction→architect). The compiled package's static role is a section-ordering
  input, NOT the attribution authority; an unknown lane is inert.
- app.go / main.go: the default watcher becomes the bundle watcher; Options gains
  SubmitHMACKey (read from LD_SUBMIT_HMAC_KEY), so the whole product path is
  inert until the operator flips it.
- spawn/fixture.go: the fixture now MODELS `test -f <path>` from its own file map
  (a fixture that answers "present" for every path would make every watcher test
  a lie); a scripted ExecResult still wins.

Tests (Verify lines 2 and 3, mechanized through a REAL gateway over httptest, the
real mint path and the real verifier): the roundtable record lands and the lane
ends on the bundle with disposition=completed / evidence.verified=true and NO
no_result; the architect drafts land in Backlog; a lane that submits nothing
still ends reason=no_result + disposition=failed with the nonce delivered; a
self-declared contract is refused; a bundle without product-receipt is refused; a
class lane gets no nonce. Plus the extraction matrix and the malformed-contract
refusal before the claim.

Honest limit carried in the code: the nonce lives 15 minutes (min(claim deadline,
now+15m) as designed), so a run longer than that gets a 401 and its lane closes
ReasonNoResult — a loud, named failure. Re-minting on the heartbeat (or a
per-lane nonce TTL) is the follow-up; minting a longer-lived nonce here would
silently widen the boundary.
HOMELAB-1859: docs(gateway): the result gateway's README and the dispatcher table row
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 8s
CI Review / alert-receiver-test (pull_request) Successful in 19s
CI Review / dispatcher-test (pull_request) Failing after 4m53s
c174597919
The split R5 asks for, stated where a reader will find it: the dispatcher owns
state/leases with one shared key, the gateway owns product writes with per-role
keys, and the sandbox holds only a dispatch nonce. Plus the API in one screen, the
honest limits (single-replica replay guarantee, the 15-minute submission window,
body_sha256 bound but not yet independently re-derived) and the operator commands
(--check, the no-nonce 401 probe).
HOMELAB-1859: fix(spawn): read the fixture's file map under its lock (race found by -race)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 3m32s
c0a9abddd6
The `test -f` probe held a reference to the inner file map and indexed it after
unlocking. That looks harmless and is not: the sandbox (another goroutine in a
lane test) writes into the same inner map, and Go maps are not safe for
concurrent access even through two references. `go test -race` caught it on the
first run of the lane test — which is exactly the property the race detector is
enforced for in this module's CI (HOMELAB-1816).

Also merged origin/epic/LD4 (HOMELAB-1850's routines phase census, #594) so the
PR's diff against the epic base is only this ticket's changes.
HOMELAB-1859: merge epic/LD4 into the gateway branch — union the pinned identity totals
All checks were successful
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 19s
CI Review / dispatcher-test (pull_request) Successful in 3m5s
7b276a7e3b
origin/epic/LD4 moved after this branch was cut (HOMELAB-1690's friction package,
#592), which also activated LD4.5b's `on-call` role. The merge conflicts in five
files, all of them the SAME additive edit on both sides, so each is resolved as a
union rather than by preferring one side:

- identity/example-registry.yaml + model.go: `activeRoles` and
  ActiveRolesInV1() now carry BOTH LD4.5b's `on-call` and this ticket's
  `roundtable` (8 active roles). The registry test compares the file's
  declaration order with the Go list, so both are ordered on-call then
  roundtable.
- identity/registry_test.go + surfaces_test.go: the pinned totals move to their
  true merged values — 10 roles described (8 active + scout + el-director),
  43 identity entries (9 full four-surface sets + sendrast + el-director's 3)
  and 11 chat accounts.
- Dockerfile: FOUR binaries now (dispatcher, routines, alertintake from
  HOMELAB-1847, ld-result-gateway from this ticket), each built and copied; the
  header comment and the EXPOSE line union both sides' reasons.

No product code was changed by the resolution: the gateway, the lane watcher,
the spawn nonce upload and the identity activation are untouched.

go vet ./... and go test -race ./... are green over the merged tree, and
`routines -check` accepts the merged registry (27 routines: 5 active, 22 paused).
Author
Owner

Review verdict — APPROVE (no blocker findings), on head 7b276a7 after an integration union

Reviewed in a fresh context from primary sources (the diff, HOMELAB-1859's adjudication, the design brief, the ticket's Verify lines). Reviewer ≠ implementer.

Integration first (this PR's head moved)

origin/epic/LD4 advanced after this branch was cut (#592, HOMELAB-1690's friction package — which also activated LD4.5b's on-call). Merging it produced conflicts in exactly five files, every one the SAME additive edit on both sides. Resolved as unions in 7b276a7, no product code touched:

  • identity/example-registry.yaml + model.go: activeRoles and ActiveRolesInV1() carry both on-call and roundtable (8 active roles), ordered on-call-then-roundtable so the file/Go order comparison still holds.
  • identity/registry_test.go + surfaces_test.go: pinned totals to their true merged values — 10 roles described, 43 identity entries, 11 chat accounts (9 full four-surface sets + sendrast + el-director's 3).
  • Dockerfile: four binaries (dispatcher, routines, alertintake, ld-result-gateway); header + EXPOSE line union both sides' reasons. Note :8091 is shared with ALERT_INTAKE_LISTEN by number only — a mgmt-VM host unit vs a pod, no shared network path.

go vet ./..., go test -race ./... (whole module) and routines -check (27 routines, 5 active / 22 paused) are green locally; CI is green on this head (dispatcher-test 3m05s).

The security core — verified

  • No Plane credential reaches any sandbox. Grepped the entire diff for token/PAT handling: the only credential-shaped artifact delivered into a sandbox is the HMAC dispatch nonce (.sandbox-init/submit-token, 0600 via the sandbox's own chmod, outside the pin-verified package dir). Per-role Plane PATs live only in the gateway process (env from the registry's TokenRefFor → mounted Secret), and SpawnEvidence records the nonce's path + digest — String() deliberately omits even the digest. The dispatcher mounts only LD_SUBMIT_HMAC_KEY.
  • Nonce: MAC over NUL-separated role‖ticket‖lane‖expiry, hmac.Equal compare, Bearer-only (every other scheme refused), key floor 16 bytes enforced at both mint and verify, expiry min(claim deadline, now+15m). The brief's MAC-only shorthand is unverifiable by construction, so the <b64(payload)>.<b64(mac)> deviation is correct and documented.
  • Replay — confirmed stronger than documented at 1 replica: reserve() takes the ledger slot inside one critical section before the write and release()s it only on failure, so two concurrent same-nonce submissions cannot both write; LedgerTTL (30m) > NonceTTL (15m), so there is no forget-then-replay window. A deliberate double-POST across N replicas remains the documented TOCTOU (the Deployment runs 1). Honest.
  • Verb charter / Backlog-only / strict decode: Submission has no project/state/target/assignee field, unknown fields are ErrUnknownField (400) rather than ignored, BacklogState is a constant (resolved by name or the backlog group; a project without one is refused, never approximated), charter is closed (roundtable comment; architect comment+drafts; all else 403). The path-form asserted ticket is checked against the nonce and never honored. Every refusal is a named fixture test asserting zero Plane calls; wire shapes pinned (one-field comment body; name/description_html/state only).
  • Lane end: resultBundleWatcher ends on the bundle; teardown/* is untouched (0 files in the diff) and ReasonNoResult keeps its meaning — the TTL fallback and the "nothing submitted → no_result + failed, nonce still delivered" regression live in the same test file. Gateway-wired lanes must declare a product-receipt bound to the lane's ticket/role/lane, and a self-declared contract is refused at the decoder.
  • R2 ordering: the spawn nonce upload is commit 3 on this branch (f6a944c) and the E-adj rule is prod #888's 5761770, in an unmerged PR. Order holds.

Verify lines

1 and 2 are fixture-mechanized as claimed (2 runs end-to-end through a real gateway on httptest, real mint/verify); 3 is honestly fixture-only, with live landing deferred to activation — stated in both PR bodies, not overclaimed. The test -f probe shape is the contract's (execute_command runs cwd=/workspace), and its failure mode is fail-safe (probe error → keep waiting → TTL no_result, logged).

Non-blocking concerns (mergeable; posted, not fixed here)

  1. mountedTokens keys the token map by tokenRef but renders the env var from the role alone (LD_PLANE_TOKEN_<ROLE>). Two instances of one chartered role would therefore share one credential and cross-attribute. Unreachable today (both chartered roles are single-instance); worth closing before a chartered role gains an instance.
  2. A draft_items submission that fails after the Nth create releases the nonce, so a retry re-creates the earlier items. Impact is low (Backlog items are inert), but the duplication is silent.
  3. The 15-minute window is assessed as acceptable and fail-loud — no boundary widening, re-mint-on-heartbeat named as the follow-up. Worth prioritizing if the first live run exceeds it, because a product already written to Plane can coexist with a lane that then closes no_result.
  4. Config.ProjectID is required but the write path uses HTTPPlane's own project id — redundant but harmless.

Out of scope here: the one class egress rule is prod #888 (light widened with exactly one rule → gateway :8091; Plane still absent). That PR is a dangerous-class, E-adj change and stays for operator review — do not auto-merge it.

## Review verdict — APPROVE (no blocker findings), on head `7b276a7` after an integration union Reviewed in a fresh context from primary sources (the diff, HOMELAB-1859's adjudication, the design brief, the ticket's Verify lines). Reviewer ≠ implementer. ### Integration first (this PR's head moved) `origin/epic/LD4` advanced after this branch was cut (`#592`, HOMELAB-1690's friction package — which also activated LD4.5b's `on-call`). Merging it produced conflicts in exactly five files, every one the SAME additive edit on both sides. Resolved as unions in `7b276a7`, no product code touched: * `identity/example-registry.yaml` + `model.go`: `activeRoles` and `ActiveRolesInV1()` carry **both** `on-call` and `roundtable` (8 active roles), ordered on-call-then-roundtable so the file/Go order comparison still holds. * `identity/registry_test.go` + `surfaces_test.go`: pinned totals to their true merged values — **10 roles described, 43 identity entries, 11 chat accounts** (9 full four-surface sets + `sendrast` + el-director's 3). * `Dockerfile`: **four** binaries (dispatcher, routines, alertintake, ld-result-gateway); header + `EXPOSE` line union both sides' reasons. Note :8091 is shared with `ALERT_INTAKE_LISTEN` by number only — a mgmt-VM host unit vs a pod, no shared network path. `go vet ./...`, `go test -race ./...` (whole module) and `routines -check` (27 routines, 5 active / 22 paused) are green locally; CI is green on this head (dispatcher-test 3m05s). ### The security core — verified * **No Plane credential reaches any sandbox.** Grepped the entire diff for token/PAT handling: the only credential-shaped artifact delivered into a sandbox is the HMAC dispatch nonce (`.sandbox-init/submit-token`, 0600 via the sandbox's own `chmod`, outside the pin-verified package dir). Per-role Plane PATs live only in the gateway process (env from the registry's `TokenRefFor` → mounted Secret), and `SpawnEvidence` records the nonce's *path + digest* — `String()` deliberately omits even the digest. The dispatcher mounts only `LD_SUBMIT_HMAC_KEY`. * **Nonce:** MAC over NUL-separated `role‖ticket‖lane‖expiry`, `hmac.Equal` compare, `Bearer`-only (every other scheme refused), key floor 16 bytes enforced at both mint and verify, expiry `min(claim deadline, now+15m)`. The brief's MAC-only shorthand is unverifiable by construction, so the `<b64(payload)>.<b64(mac)>` deviation is correct and documented. * **Replay — confirmed stronger than documented at 1 replica:** `reserve()` takes the ledger slot inside one critical section *before* the write and `release()`s it only on failure, so two concurrent same-nonce submissions cannot both write; `LedgerTTL` (30m) > `NonceTTL` (15m), so there is no forget-then-replay window. A deliberate double-POST across N replicas remains the documented TOCTOU (the Deployment runs 1). Honest. * **Verb charter / Backlog-only / strict decode:** `Submission` has no project/state/target/assignee field, unknown fields are `ErrUnknownField` (400) rather than ignored, `BacklogState` is a constant (resolved by name or the `backlog` group; a project without one is refused, never approximated), charter is closed (`roundtable` comment; `architect` comment+drafts; all else 403). The path-form asserted ticket is checked against the nonce and never honored. Every refusal is a named fixture test asserting **zero Plane calls**; wire shapes pinned (one-field comment body; `name`/`description_html`/`state` only). * **Lane end:** `resultBundleWatcher` ends on the bundle; `teardown/*` is untouched (0 files in the diff) and `ReasonNoResult` keeps its meaning — the TTL fallback and the "nothing submitted → `no_result` + `failed`, nonce still delivered" regression live in the same test file. Gateway-wired lanes must declare a `product-receipt` bound to the lane's ticket/role/lane, and a self-declared contract is refused at the decoder. * **R2 ordering:** the spawn nonce upload is commit 3 on this branch (`f6a944c`) and the E-adj rule is prod #888's `5761770`, in an unmerged PR. Order holds. ### Verify lines 1 and 2 are fixture-mechanized as claimed (2 runs end-to-end through a real gateway on `httptest`, real mint/verify); 3 is honestly fixture-only, with live landing deferred to activation — stated in both PR bodies, not overclaimed. The `test -f` probe shape is the contract's (`execute_command` runs `cwd=/workspace`), and its failure mode is fail-safe (probe error → keep waiting → TTL `no_result`, logged). ### Non-blocking concerns (mergeable; posted, not fixed here) 1. `mountedTokens` keys the token map by `tokenRef` but renders the env var from the **role** alone (`LD_PLANE_TOKEN_<ROLE>`). Two instances of one chartered role would therefore share one credential and cross-attribute. Unreachable today (both chartered roles are single-instance); worth closing before a chartered role gains an instance. 2. A `draft_items` submission that fails after the Nth create releases the nonce, so a retry re-creates the earlier items. Impact is low (Backlog items are inert), but the duplication is silent. 3. The 15-minute window is assessed as **acceptable and fail-loud** — no boundary widening, re-mint-on-heartbeat named as the follow-up. Worth prioritizing if the first live run exceeds it, because a product already written to Plane can coexist with a lane that then closes `no_result`. 4. `Config.ProjectID` is required but the write path uses `HTTPPlane`'s own project id — redundant but harmless. **Out of scope here:** the one class egress rule is prod #888 (`light` widened with exactly one rule → gateway `:8091`; Plane still absent). That PR is a dangerous-class, E-adj change and stays for operator review — do not auto-merge it.
aaron merged commit 7366125b1d into epic/LD4 2026-10-03 04:30:02 +00:00
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!597
No description provided.