HOMELAB-1685: LD4.1 intake — direction model, sweep arm, plane context author, durable inbox #568

Merged
aaron merged 5 commits from plane/HOMELAB-1685-intake into epic/LD4 2026-10-02 01:25:35 +00:00
Owner

What

LD4.1 · intake (epic LD4), built to .rpiv/artifacts/epic-ld4/design-LD4.1-intake.md with the orchestrator rulings from the HOMELAB-1685 comments (O1 items in homelab + labels [lab-director, intake] + module · O3 dispatcher arm · O4 RocketChat rail with an explicit DM extension · O5 class-E = immediate ACT + SLA ladder · O6 no role-lore seed · O7 no registry entries; O2 proceeds on the scout recommendation — the spool is a file drained into Plane, flagged for the operator's contract ruling).

Design decisions as built (D1–D5)

# Decision Where
D1 Three-tier store: Plane = state (direction-inbox items), JetStream = ledger (payload only in this PR), bridge spool = transport (never authoritative) direction/ledger.go (ld.direction/v1), lib/spool.js (header + README)
D2 Queue-and-replay lives in the bridge; the digest loop never touches Signal lib/spool.js, lib/inbox.js, lib/router.js (deliverOrQueue)
D3 Sweep = leader-elected dispatcher arm (no LLM) creating ONE digest execution issue per window; the digest itself is a sandbox the existing watch→claims→compiler→spawn chain dispatches intake/, cmd/dispatcher/arms wiring
D4 Architect handoff by class→template routing; intake never drafts tickets config.TemplateKindDirection/Intake, context_plane.go LaneFor, direction.RenderDigestBody
D5 R-04 rides a notifier seam (RocketChat v1); Signal is a named follow-up, which is how FINDING V2 is discharged instead of re-inherited notifier/ (file header carries the rail table and the rationale)

The three hot-loop guards (written into the headers before the loops)

  1. One writer per item class — the bridge creates inbox items, the arm creates digest execution issues, the digest sandbox only stamps.
  2. direction.WindowKey idempotency — a second sweep inside the interval plans the same window and writes nothing.
  3. Read-before-write (FindDigestIssue) — closes the window between "created in Plane" and "recorded in the ledger".

R1: the per-ticket context author (the critical-path slice)

cmd/dispatcher/context_plane.go implements the ContextSource whose absence made main.go say "the dispatcher watches and refuses to claim".

  • The ticket-and-acceptance-criteria section is authored from the Plane item, verbatim, and pinned to that revision's digest.
  • The other seven sections come from the mounted tree exactly as context.author: files reads them. They are not fabricated from the ticket body.
  • Staleness fails loud twice: compiler.Compile re-derives every pin (existing mechanism), and the author re-reads the item after compiling and refuses with a *compiler.CompileError (ReasonStale, section named) when the revision moved. Cost: one extra Plane GET per dispatch.

Routing (D4, and the "two taxonomies" trip hazard)

direction and intake are template keys, not A–E class letters (config.TemplateKinds(); the "two taxonomies, made explicit" note is extended rather than contradicted). The lane is read from the item's own declaration:

  • digest execution issue (ld:direction-digest + intake label) → templates.intake (fleet-intake sandbox);
  • authored direction digest (marker, no intake label) → templates.direction (architect drafts);
  • anything else → "", the class binding stands.

An unbound lane is refused before the claim, same posture as an unbound class.

E-adj / dangerous hunks in THIS PR

None. This PR is code + config-schema + a test suite. No CNP change, no ESO secret, no kubectl, no live mutation, no cluster write. The E-adj work (NATS ingress from agent-tools, dispatcher egress to :4222, the plane-intake-externalsecret, the prod ConfigMap keys) is a separate PR against the prod repo, clearly flagged as REQUIRING OPERATOR REVIEW and not applied. Until it lands, the ledger publisher is the log-only implementation and the arm is fully functional without the bus.

Shared-file edits (for integration merges — small, localized)

File Edit
cmd/dispatcher/app.go Options: intake seams; newSweeper(); plane author selection; buildRunners adds runner{name: "intake", fatal: false}
cmd/dispatcher/pipeline.go LaneResolver + contextSourceWithClass seams; lane resolution in handle before the class binding; laneOrClass
config/config.go ContextAuthorPlane; Intake struct + policy; Arms.Intake; TemplateKindDirection/Intake, TemplateKinds(), TemplateKindOf(); Context.SpecFor/DerivedSection (Spec delegates with nil); defaults; Config.String
config/load.go plane-mode validation (ticket section derived ⇒ its source/pin not required); the two template-key cases; intake keys
config/example.go intake: block, arms.intake, the two template keys, context.author doc
identity/model.go ActiveRolesInV1 += intake (the loader refuses an active role missing a surface — R6)
identity/example-registry.yaml ONLY the intake role: activeRoles entry + four surface bindings. Sibling builders own other roles
identity/registry_test.go, identity/surfaces_test.go the three count assertions that pin the artifact (roles 6→7, entries 28→32, chat invite list 7→8)

Deviations from the brief (stated, not hidden)

  1. templates.direction/templates.intake default to ld-sandbox, not ld-architect/ld-intake (the brief's example). Those SandboxTemplates do not exist yet, and a default naming a template the spawn arm cannot create would make every digest dispatch fail at spawn. Rebind in the ConfigMap once the role templates land.
  2. direction.Policy is a value + config keys, not a separate policy.md file: LD4.4 owns the registry/schedule record (O7), and the numbers are cited in intake.softAfter/hardAfter.
  3. R-04's decision-queue read is an optional interface (intake.DecisionReader). The mechanism (direction.PlanNudges + the notifier rail + the ledger-backed emitted set) is real and tested; the live item grammar is the same marker discipline the inbox uses. A reader without it logs the gap once rather than failing the sweep.
  4. The digest sandbox templates (ld-intake, ld-architect) and their per-role Plane keys are follow-ups — the brief's own (2) for D4: today the boundary is schema + routing, attributed by identity, not authorisation-enforced.

Test plan → ticket Verify items

dispatcher: go test -race ./... + go vet ./... green; no test opens a socket (fixtures: watch.FixtureClient, claims.FixtureWriter, spawn.FixtureMCPClient, an injected HTTPDoer, a temp-dir spool, injected timers/clocks).

Verify Evidence
a Signal message sent with no session live is digested on the next sweep Node: orphan → spooled + acked verbatim (QUEUED_REPLY equality), spool-full refuses loudly, LINK replays in spool order as [signal] …, drain marks only after Plane confirms, drain is idempotent on the dedup key, wire format == the Go fixture byte for byte. Go: TestOneSweepCreatesExactlyOneDigestExecutionIssue — plant an undigested item → one sweep → exactly one digest execution issue (labels [lab-director, intake], state Todo, body verbatim) → second sweep → still one (window guard)
decomposition appears as architect-drafted tickets Go: TestADigestExecutionIssueRoutesToTheIntakeLane (end to end through handle, MCP fixture shows template ld-intake for the execution issue and ld-architect for the authored digest), TestThePlaneAuthorCompilesAllEightSectionsAndPinsTheTicketToItsRevision, TestThePlaneAuthorFailsLoudWhenTheItemIsRevisedMidCompile (compiler.IsStale), TestAnUnboundLaneIsRefusedBeforeTheClaim (zero Plane writes)
R-04 (mechanism) direction boundary table (23h59 → none, 24h → FYI, 72h → ACT, class-E immediate + exposure bound required); intake nudge scan: one nudge per (item, level), emitted set read from the ledger so it survives a restart

Not in this PR (deliberate)

Live kubectl/cluster anything; JetStream publish (CNP-gated); the prod manifests (second PR); fleet-intake role lore seed and per-role Plane keys (O6 / follow-up); persona/routines (sibling builders).

Do not merge — landing is the landing worker's job (fresh-context review → merge on green CI + clean verdict).

## What LD4.1 · intake (epic LD4), built to `.rpiv/artifacts/epic-ld4/design-LD4.1-intake.md` with the orchestrator rulings from the HOMELAB-1685 comments (O1 items in `homelab` + labels `[lab-director, intake]` + module · O3 dispatcher arm · O4 RocketChat rail with an explicit DM extension · O5 class-E = immediate ACT + SLA ladder · O6 no role-lore seed · O7 no registry entries; O2 proceeds on the scout recommendation — the spool is a file drained into Plane, flagged for the operator's contract ruling). ## Design decisions as built (D1–D5) | # | Decision | Where | |---|---|---| | D1 | Three-tier store: **Plane = state** (direction-inbox items), **JetStream = ledger** (payload only in this PR), **bridge spool = transport** (never authoritative) | `direction/ledger.go` (`ld.direction/v1`), `lib/spool.js` (header + README) | | D2 | Queue-and-replay lives in the bridge; the digest loop never touches Signal | `lib/spool.js`, `lib/inbox.js`, `lib/router.js` (`deliverOrQueue`) | | D3 | Sweep = leader-elected dispatcher arm (no LLM) creating ONE digest execution issue per window; the digest itself is a sandbox the existing watch→claims→compiler→spawn chain dispatches | `intake/`, `cmd/dispatcher/arms` wiring | | D4 | Architect handoff by class→template routing; intake never drafts tickets | `config.TemplateKindDirection/Intake`, `context_plane.go` `LaneFor`, `direction.RenderDigestBody` | | D5 | R-04 rides a notifier seam (RocketChat v1); Signal is a named follow-up, which is how FINDING V2 is discharged instead of re-inherited | `notifier/` (file header carries the rail table and the rationale) | ### The three hot-loop guards (written into the headers before the loops) 1. **One writer per item class** — the bridge creates inbox items, the arm creates digest execution issues, the digest sandbox only stamps. 2. **`direction.WindowKey` idempotency** — a second sweep inside the interval plans the same window and writes nothing. 3. **Read-before-write** (`FindDigestIssue`) — closes the window between "created in Plane" and "recorded in the ledger". ## R1: the per-ticket context author (the critical-path slice) `cmd/dispatcher/context_plane.go` implements the `ContextSource` whose absence made `main.go` say *"the dispatcher watches and refuses to claim"*. - The **ticket-and-acceptance-criteria** section is authored from the Plane item, verbatim, and pinned to that revision's digest. - The **other seven** sections come from the mounted tree exactly as `context.author: files` reads them. They are **not** fabricated from the ticket body. - **Staleness fails loud twice**: `compiler.Compile` re-derives every pin (existing mechanism), and the author re-reads the item after compiling and refuses with a `*compiler.CompileError` (`ReasonStale`, section named) when the revision moved. Cost: one extra Plane GET per dispatch. ## Routing (D4, and the "two taxonomies" trip hazard) `direction` and `intake` are **template keys, not A–E class letters** (`config.TemplateKinds()`; the "two taxonomies, made explicit" note is extended rather than contradicted). The lane is read from the item's own declaration: - digest **execution** issue (`ld:direction-digest` + `intake` label) → `templates.intake` (fleet-intake sandbox); - authored **direction digest** (marker, no intake label) → `templates.direction` (architect drafts); - anything else → `""`, the class binding stands. An unbound lane is refused **before the claim**, same posture as an unbound class. ## E-adj / dangerous hunks in THIS PR **None.** This PR is code + config-schema + a test suite. No CNP change, no ESO secret, no `kubectl`, no live mutation, no cluster write. The E-adj work (NATS ingress from `agent-tools`, dispatcher egress to `:4222`, the `plane-intake-externalsecret`, the prod ConfigMap keys) is a **separate PR against the `prod` repo**, clearly flagged as **REQUIRING OPERATOR REVIEW** and not applied. Until it lands, the ledger publisher is the log-only implementation and the arm is fully functional without the bus. ## Shared-file edits (for integration merges — small, localized) | File | Edit | |---|---| | `cmd/dispatcher/app.go` | `Options`: intake seams; `newSweeper()`; plane author selection; `buildRunners` adds `runner{name: "intake", fatal: false}` | | `cmd/dispatcher/pipeline.go` | `LaneResolver` + `contextSourceWithClass` seams; lane resolution in `handle` before the class binding; `laneOrClass` | | `config/config.go` | `ContextAuthorPlane`; `Intake` struct + policy; `Arms.Intake`; `TemplateKindDirection/Intake`, `TemplateKinds()`, `TemplateKindOf()`; `Context.SpecFor`/`DerivedSection` (Spec delegates with nil); defaults; `Config.String` | | `config/load.go` | plane-mode validation (ticket section derived ⇒ its source/pin not required); the two template-key cases; intake keys | | `config/example.go` | `intake:` block, `arms.intake`, the two template keys, `context.author` doc | | `identity/model.go` | `ActiveRolesInV1` += `intake` (the loader **refuses** an active role missing a surface — R6) | | `identity/example-registry.yaml` | ONLY the `intake` role: activeRoles entry + four surface bindings. Sibling builders own other roles | | `identity/registry_test.go`, `identity/surfaces_test.go` | the three count assertions that pin the artifact (roles 6→7, entries 28→32, chat invite list 7→8) | ## Deviations from the brief (stated, not hidden) 1. **`templates.direction`/`templates.intake` default to `ld-sandbox`**, not `ld-architect`/`ld-intake` (the brief's example). Those SandboxTemplates do not exist yet, and a default naming a template the spawn arm cannot create would make every digest dispatch fail at spawn. Rebind in the ConfigMap once the role templates land. 2. **`direction.Policy` is a value + config keys**, not a separate `policy.md` file: LD4.4 owns the registry/schedule record (O7), and the numbers are cited in `intake.softAfter`/`hardAfter`. 3. **R-04's decision-queue read is an optional interface** (`intake.DecisionReader`). The mechanism (`direction.PlanNudges` + the notifier rail + the ledger-backed emitted set) is real and tested; the live item grammar is the same marker discipline the inbox uses. A reader without it logs the gap once rather than failing the sweep. 4. **The digest sandbox templates (`ld-intake`, `ld-architect`) and their per-role Plane keys are follow-ups** — the brief's own (2) for D4: today the boundary is schema + routing, attributed by identity, not authorisation-enforced. ## Test plan → ticket Verify items `dispatcher`: `go test -race ./...` + `go vet ./...` green; **no test opens a socket** (fixtures: `watch.FixtureClient`, `claims.FixtureWriter`, `spawn.FixtureMCPClient`, an injected `HTTPDoer`, a temp-dir spool, injected timers/clocks). | Verify | Evidence | |---|---| | **a Signal message sent with no session live is digested on the next sweep** | Node: orphan → spooled + acked **verbatim** (`QUEUED_REPLY` equality), spool-full refuses loudly, `LINK` replays in spool order as `[signal] …`, drain marks only after Plane confirms, drain is idempotent on the dedup key, wire format == the Go fixture byte for byte. Go: `TestOneSweepCreatesExactlyOneDigestExecutionIssue` — plant an undigested item → one sweep → **exactly one** digest execution issue (labels `[lab-director, intake]`, state Todo, body verbatim) → **second sweep → still one** (window guard) | | **decomposition appears as architect-drafted tickets** | Go: `TestADigestExecutionIssueRoutesToTheIntakeLane` (end to end through `handle`, MCP fixture shows template `ld-intake` for the execution issue and `ld-architect` for the authored digest), `TestThePlaneAuthorCompilesAllEightSectionsAndPinsTheTicketToItsRevision`, `TestThePlaneAuthorFailsLoudWhenTheItemIsRevisedMidCompile` (`compiler.IsStale`), `TestAnUnboundLaneIsRefusedBeforeTheClaim` (zero Plane writes) | | R-04 (mechanism) | `direction` boundary table (23h59 → none, 24h → FYI, 72h → ACT, class-E immediate + exposure bound required); `intake` nudge scan: one nudge per (item, level), emitted set read from the **ledger** so it survives a restart | ## Not in this PR (deliberate) Live `kubectl`/cluster anything; JetStream publish (CNP-gated); the prod manifests (second PR); `fleet-intake` role lore seed and per-role Plane keys (O6 / follow-up); persona/routines (sibling builders). Do not merge — landing is the landing worker's job (fresh-context review → merge on green CI + clean verdict).
The LD4.1 data model and mechanism, fixture-first and pure where it can be:

- direction/: Item (verbatim body, open source enum, DedupKey, priority
  refused without a citation), the append-only ledger value
  (ld.direction/v1) with a log-only v1 default, the idempotent sink seam,
  the pure R-11 window planner (WindowKey + PlanWindow), and the R-04 SLA
  ladder (24h FYI / 72h ACT, class E pages immediately per O5).
- notifier/: the human-notification seam that discharges FINDING V2. v1 rail
  is RocketChat (reachable today); the DM gap in rcbridge.ChatClient is
  documented and resolved through an explicit optional DirectMessenger
  extension (O4). LogNotifier is the loud default.
- intake/: the sweep arm (rcTopology's non-fatal shape, injectable clock and
  timer), the Plane read surface (marker wire format shared with the Node
  bridge), the label/state resolvers (O1 labels, dispatchable digest state),
  the read-before-write digest guard, and the narrow HTTP digest writer.

No hot loop: one writer per item class, window-key idempotency, and the
read-before-write guard (all three written into the package header).
R1 (the critical-path slice): cmd/dispatcher/context_plane.go implements the
live per-ticket ContextSource whose absence made the dispatcher refuse every
claim. The ticket-and-acceptance-criteria section is authored from the Plane
item (verbatim) and pinned to that revision's digest; the other seven sections
come from the mounted tree, exactly as context.author: files reads them, and are
NOT fabricated from the ticket body. Staleness fails loud twice: compiler.Compile
re-derives every pin, and the author re-reads the item after compiling and
refuses with a *compiler.CompileError (ReasonStale, section named) when the
revision moved.

Routing: the lane resolver reads the item's own declaration (the ld:direction
marker + the intake label) and maps a digest execution issue to the `intake`
template key and an authored direction digest to `direction` — template keys,
not A-E class letters (the config's own two-taxonomies note). An unbound lane is
refused BEFORE the claim, like an unbound class.

Wiring: arms.intake registered (non-fatal), intake.* config keys (interval,
softAfter/hardAfter, labels, digestState, channel, dmUser), context.author:
plane, templates.direction/intake (defaulting to the generic lane until the
role sandbox templates exist), Options intake seams, README-free (the package
headers carry the design).

Acceptance evidence in cmd/dispatcher/intake_pipeline_test.go: plant an
undigested direction item -> one sweep -> exactly one digest execution issue ->
second sweep creates none; the routing end to end through the core loop; the
plane author's eight sections + pin; the staleness refusal; an unbound lane
refused before the claim.
R6 (the cheap-to-fix, easy-to-forget risk): ActiveRolesInV1 omits intake and the
registry loader refuses an active role that is missing a surface. The role is
activated as DATA — four bindings (plane, forgejo, rocketchat, herdr) with
tokenRefs into OpenBao — exactly the way model.go documents ('adding a role is a
registry edit, never a code change here'). Empty role lore is NOT part of LD4.1
(O6). The three count assertions that pin the artifact (described roles, entries,
chat invite list) move with it.
HOMELAB-1685: feat(signal-bridge): durable inbox — queue, replay, drain
Some checks are pending
Plan in progress...
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 19s
CI Review / dispatcher-test (pull_request) Successful in 2m7s
30d867b47d
The ticket's premise: lib/router.js answered an orphan inbound with an apology and
dropped it. Now the message is persisted BEFORE the operator is told anything, and
the ack is a fact rather than a hope.

- lib/spool.js: append-only JSONL spool, fsync'd before the ack returns, bounded by
  records AND bytes, mode 0600, pruned once every record has landed. The invariant
  that matters: a NON-DRAINED record is never evicted — on overflow the append is
  REFUSED and the operator is told, because silent loss is the failure this ticket
  exists to remove. Dispositions: queued / drained_to_plane:<id> /
  delivered_to_session:<target>.
- lib/plane.js: the minimum Plane write. One writer per item class (the hot-loop
  guard), idempotent on the dedup key (read-before-create, since Plane has no
  constraint this service can use), credential as a header and never a payload.
  The description wire format (marker line + one JSON line) mirrors
  intake.EncodeDirectionItem byte for byte, and BOTH suites assert the same rendered
  string.
- lib/inbox.js: spool -> Plane -> mark, in that order: a record is marked only
  after Plane confirmed it, so a crash mid-drain re-delivers (idempotently) instead
  of losing. Failures stop the pass and keep the spool's order.
- lib/router.js: deliverOrQueue() replaces the drop; QUEUED_REPLY is asserted
  VERBATIM by the suite; the spool-full refusal is loud; /inbox <text> forces the
  inbox path; LINK replays the backlog in spool order as [signal] …; the spool
  stores a sender digest, never the number.
- bridge.js: drain BEFORE the Signal/intercom streams start (a restart replays
  first) and on DRAIN_INTERVAL; disabled-with-a-named-reason when PLANE_* is unset.
- README + env.example: the contract, the disposition ladder, the boundedness, the
  honest residual risk, and the new events.

23 Node tests, no socket: mkdtemp spool, injected fetchImpl, injected timers.
Author
Owner

VERDICT: APPROVED

Fresh-context review of head 30d867b (base epic/LD4 @ cd6db02, CI green: dispatcher-test -race, alert-receiver, receiver, helm-validate). I independently re-ran the gates in the PR worktree: go vet ./... clean, go test -race ./... green for every package, node --test test/bridge.test.js 23/23 pass. No blockers. Concerns C1–C4 below are posted per the landing contract and do not gate the merge.

Verify items — re-derived, not taken on faith

Both named tests exist and assert what the body claims:

  • cmd/dispatcher/intake_pipeline_test.go:176 TestOneSweepCreatesExactlyOneDigestExecutionIssue — plant one undigested item → exactly 1 write → asserts body carries the direction verbatim, labels lbl-lab-director,lbl-intake, state Todo, ArmIntake registered; second sweep → still 1 (window guard). Real acceptance for Verify item 1's Go half, and the Node half is covered by test/bridge.test.js orphan-queue/ack/replay cases.
  • :217 TestADigestExecutionIssueRoutesToTheIntakeLane (exec issue → ld-intake, authored digest → ld-architect, ordinary ticket → no lane) and :267 TestAnUnboundLaneIsRefusedBeforeTheClaim (zero Plane transitions, zero MCP Create). That is Verify item 2's routing half end-to-end through dispatch.handle.
    Deviations 1–4 are stated honestly in the body and all four are acceptable for v1: (1) templates.direction/intake → ld-sandbox is the correct conservative choice — config/config.go:869-879 explains that naming a nonexistent SandboxTemplate fails every digest dispatch at spawn; (2) policy-as-config matches O7 (LD4.4 owns the registry record); (4) the missing ld-intake/ld-architect templates and per-role Plane keys are the brief's own follow-ups. (3) is C1.

R1 (context author) — verified real

cmd/dispatcher/context_plane.go:87 reads the item, renders the ticket section verbatim from the Plane description (renderTicketSection, metadata preamble + body unmodified), pins it to the digest of exactly those bytes, then re-reads the item after compiling and refuses with *compiler.CompileError/ReasonStale naming SectionTicket (:130-150). This is not pinning-by-hope: watch/planeapi.go:297,325 populates UpdatedAt from updated_at, so Issue.RevisionAt() (watch/client.go:68-73) is a live revision axis, and the behaviour is asserted by TestThePlaneAuthorFailsLoudWhenTheItemIsRevisedMidCompile (:331) via a client that bumps the revision between reads. TestThePlaneAuthorRefusesAPackageForAnotherTicket (:372) covers the identifier-mismatch path. isStale semantics match the compiler's own (compiler.IsStale).

Hot-loop guards — present in code

  1. One writer per item class: bridge creates inbox items (lib/plane.js), arm creates digest issues (intake/plane.go HTTPDigestWriter), sandbox only stamps — documented at the header of each writer.
  2. WindowKey idempotency: direction/sweep.go:WindowKey + PlanWindow AlreadyOpen, fed by Sweeper.openWindows reading KindSweep ledger entries; LogLedger.List is in-process so guard 2 works before the JetStream adapter lands.
  3. Read-before-write: intake/plane.go:FindDigestIssue consulted at intake/intake.go:218-233 before any write, ordered create→ledger so a crash leaves an issue guard 3 finds rather than a phantom entry.

Node bridge

Ack is verbatim (QUEUED_REPLY asserted by equality), a full spool refuses loudly (SpoolFullError) and Spool.prune() filters to QUEUED only so a non-drained record is never evicted, drain marks only after Plane confirms and the read-then-create on dedupKey makes a re-drain a no-op. signal-mode [signal] contract preserved on replay.

Hygiene

E-adj confirmed absent: no credential, no CNP, no ESO, no kubectl, no new network path — the only egress the diff adds is the plane HTTP writer on the dispatcher's existing Plane egress. Shared-file edits (app.go, pipeline.go, config/*, identity/*) are additive and localized; the three identity count assertions match the actual registry edit exactly (7 described roles / 32 entries / 8 chat accounts — counted in identity/example-registry.yaml). Sibling #567: zero overlapping files (routines/ + Dockerfile vs this PR's cmd/dispatcher + new packages), and origin/epic/LD4 is still at cd6db02, i.e. #567 is not merged into the epic yet (contrary to the dispatch note) — no conflict either way.

C1 (concern, medium) — R-04 nudge scan is unreachable at runtime

intake/intake.go:279 ScanDecisions is never called: Run (:151) only calls Sweep, and app.go:475 wires only a.sweeper.Run. PlaneReader does implement DecisionReader, so the mechanism is one call away, but today no nudge can ever fire — and the "logs the gap once" behaviour the body promises cannot happen either. The comment at app.go:657-658 ("the R-11 sweep, and the R-04 nudge scan on the same interval") is therefore inaccurate, as are the config/config.go:470 and :202-204 comments that pair the two. Impact: R-04 (in the ticket's What, not in Verify) is mechanism-only — tested at intake/intake_test.go:275 but dead at runtime; no hazard and no effect on either Verify item. Follow-up: wire ScanDecisions (own runner or a second tick in Run) and correct those three comments.

C2 (concern, low) — "byte for byte" wire-format claim is not true as written

test/bridge.test.js:332 pins "received_at":"2026-10-02T09:00:00.000Z" (JS toISOString always emits ms), while the Go fixture intake/plane_test.go:21 wireDescription pins "2026-10-02T09:00:00Z". The two encoders are JSON-compatible (Go parses either), and only the Go encoder is a test artifact since the bridge is the sole live writer of inbox items — so this is accuracy, not risk. The PR-body claim "wire format == the Go fixture byte for byte" and the test's own comment ("This exact string is asserted by … the Go test") should be corrected or the timestamp normalised on one side.

C3 (concern, low) — O1 labels are best-effort on the bridge side

bridge.js:75 calls plane.resolveLabelIDs(labels); on a label-endpoint failure it warns and continues with labelIDs = [], so an item can land without [lab-director, intake] even though O1 says every item carries them. The Go side refuses in the same situation (intake/plane.go ResolveLabels). No functional impact (the sweep filters by the ld:direction marker, not by label), but the asymmetry is worth a comment.

C4 (concern, low) — read amplification on the dispatch path

With context.author: plane, LaneFor (context_plane.go:154) reads the item before handle's claim and PackageForLane then reads it twice more: 3 Plane GETs per dispatch, against the body's stated "one extra Plane GET per dispatch". Read-only and pre-claim, so no hot-loop risk, but worth noting for Plane load at fleet scale.

Suggestions (non-blocking)

  • direction/sweep.go:144 validates every item before the Undigested() skip, so one malformed already-stamped item permanently refuses the whole window. Intended batch-wide refusal is defensible; add a comment or skip digested items first.
  • Drainer.drainOnce (Plane) and Router.replayQueued (session) both consume QUEUED records; a LINK landing mid-drain can deliver the same message to a session and create its Plane item (Plane-side dedup makes it idempotent, at worst a visible duplicate).
  • lib/router.js:114 derives externalId from a millisecond-resolution ISO timestamp (kind:receivedAt); two messages in the same millisecond would collide and be deduped into one. The seq-based signalExternalId fallback in lib/inbox.js is never reached because the router always sets external_id.

Merging on green CI per the landing contract. Downstream: .forgejo/workflows/dispatcher-image-build.yaml triggers on push/pull_request to live only, so this epic-branch merge builds no image — no artifact expected from this landing.

VERDICT: APPROVED Fresh-context review of head `30d867b` (base `epic/LD4` @ `cd6db02`, CI green: dispatcher-test -race, alert-receiver, receiver, helm-validate). I independently re-ran the gates in the PR worktree: `go vet ./...` clean, `go test -race ./...` green for every package, `node --test test/bridge.test.js` 23/23 pass. No blockers. Concerns C1–C4 below are posted per the landing contract and do not gate the merge. ## Verify items — re-derived, not taken on faith Both named tests exist and assert what the body claims: - `cmd/dispatcher/intake_pipeline_test.go:176` `TestOneSweepCreatesExactlyOneDigestExecutionIssue` — plant one undigested item → exactly 1 write → asserts body carries the direction verbatim, labels `lbl-lab-director,lbl-intake`, state Todo, `ArmIntake` registered; second sweep → still 1 (window guard). Real acceptance for Verify item 1's Go half, and the Node half is covered by `test/bridge.test.js` orphan-queue/ack/replay cases. - `:217` `TestADigestExecutionIssueRoutesToTheIntakeLane` (exec issue → `ld-intake`, authored digest → `ld-architect`, ordinary ticket → no lane) and `:267` `TestAnUnboundLaneIsRefusedBeforeTheClaim` (zero Plane transitions, zero MCP `Create`). That is Verify item 2's routing half end-to-end through `dispatch.handle`. Deviations 1–4 are stated honestly in the body and all four are acceptable for v1: (1) `templates.direction/intake → ld-sandbox` is the correct conservative choice — `config/config.go:869-879` explains that naming a nonexistent SandboxTemplate fails every digest dispatch at spawn; (2) policy-as-config matches O7 (LD4.4 owns the registry record); (4) the missing `ld-intake`/`ld-architect` templates and per-role Plane keys are the brief's own follow-ups. (3) is C1. ## R1 (context author) — verified real `cmd/dispatcher/context_plane.go:87` reads the item, renders the ticket section verbatim from the Plane description (`renderTicketSection`, metadata preamble + body unmodified), pins it to the digest of exactly those bytes, then re-reads the item after compiling and refuses with `*compiler.CompileError`/`ReasonStale` naming `SectionTicket` (`:130-150`). This is not pinning-by-hope: `watch/planeapi.go:297,325` populates `UpdatedAt` from `updated_at`, so `Issue.RevisionAt()` (`watch/client.go:68-73`) is a live revision axis, and the behaviour is asserted by `TestThePlaneAuthorFailsLoudWhenTheItemIsRevisedMidCompile` (`:331`) via a client that bumps the revision between reads. `TestThePlaneAuthorRefusesAPackageForAnotherTicket` (`:372`) covers the identifier-mismatch path. `isStale` semantics match the compiler's own (`compiler.IsStale`). ## Hot-loop guards — present in code 1. One writer per item class: bridge creates inbox items (`lib/plane.js`), arm creates digest issues (`intake/plane.go` `HTTPDigestWriter`), sandbox only stamps — documented at the header of each writer. 2. `WindowKey` idempotency: `direction/sweep.go:WindowKey` + `PlanWindow` `AlreadyOpen`, fed by `Sweeper.openWindows` reading `KindSweep` ledger entries; `LogLedger.List` is in-process so guard 2 works before the JetStream adapter lands. 3. Read-before-write: `intake/plane.go:FindDigestIssue` consulted at `intake/intake.go:218-233` before any write, ordered create→ledger so a crash leaves an issue guard 3 finds rather than a phantom entry. ## Node bridge Ack is verbatim (`QUEUED_REPLY` asserted by equality), a full spool refuses loudly (`SpoolFullError`) and `Spool.prune()` filters to `QUEUED` only so a non-drained record is never evicted, drain marks only after Plane confirms and the read-then-create on `dedupKey` makes a re-drain a no-op. `signal-mode` `[signal] ` contract preserved on replay. ## Hygiene E-adj confirmed absent: no credential, no CNP, no ESO, no kubectl, no new network path — the only egress the diff adds is the plane HTTP writer on the dispatcher's existing Plane egress. Shared-file edits (`app.go`, `pipeline.go`, `config/*`, `identity/*`) are additive and localized; the three identity count assertions match the actual registry edit exactly (7 described roles / 32 entries / 8 chat accounts — counted in `identity/example-registry.yaml`). **Sibling #567: zero overlapping files** (`routines/` + `Dockerfile` vs this PR's cmd/dispatcher + new packages), and `origin/epic/LD4` is still at `cd6db02`, i.e. #567 is not merged into the epic yet (contrary to the dispatch note) — no conflict either way. ## C1 (concern, medium) — R-04 nudge scan is unreachable at runtime `intake/intake.go:279 ScanDecisions` is never called: `Run` (`:151`) only calls `Sweep`, and `app.go:475` wires only `a.sweeper.Run`. `PlaneReader` does implement `DecisionReader`, so the mechanism is one call away, but today no nudge can ever fire — and the "logs the gap once" behaviour the body promises cannot happen either. The comment at `app.go:657-658` ("the R-11 sweep, and the R-04 nudge scan on the same interval") is therefore inaccurate, as are the `config/config.go:470` and `:202-204` comments that pair the two. Impact: R-04 (in the ticket's What, not in Verify) is mechanism-only — tested at `intake/intake_test.go:275` but dead at runtime; no hazard and no effect on either Verify item. Follow-up: wire `ScanDecisions` (own runner or a second tick in `Run`) and correct those three comments. ## C2 (concern, low) — "byte for byte" wire-format claim is not true as written `test/bridge.test.js:332` pins `"received_at":"2026-10-02T09:00:00.000Z"` (JS `toISOString` always emits ms), while the Go fixture `intake/plane_test.go:21 wireDescription` pins `"2026-10-02T09:00:00Z"`. The two encoders are JSON-compatible (Go parses either), and only the Go encoder is a test artifact since the bridge is the sole live writer of inbox items — so this is accuracy, not risk. The PR-body claim "wire format == the Go fixture byte for byte" and the test's own comment ("This exact string is asserted by … the Go test") should be corrected or the timestamp normalised on one side. ## C3 (concern, low) — O1 labels are best-effort on the bridge side `bridge.js:75` calls `plane.resolveLabelIDs(labels)`; on a label-endpoint failure it warns and continues with `labelIDs = []`, so an item can land without `[lab-director, intake]` even though O1 says every item carries them. The Go side refuses in the same situation (`intake/plane.go ResolveLabels`). No functional impact (the sweep filters by the `ld:direction` marker, not by label), but the asymmetry is worth a comment. ## C4 (concern, low) — read amplification on the dispatch path With `context.author: plane`, `LaneFor` (`context_plane.go:154`) reads the item before `handle`'s claim and `PackageForLane` then reads it twice more: 3 Plane GETs per dispatch, against the body's stated "one extra Plane GET per dispatch". Read-only and pre-claim, so no hot-loop risk, but worth noting for Plane load at fleet scale. ## Suggestions (non-blocking) - `direction/sweep.go:144` validates every item *before* the `Undigested()` skip, so one malformed already-stamped item permanently refuses the whole window. Intended batch-wide refusal is defensible; add a comment or skip digested items first. - `Drainer.drainOnce` (Plane) and `Router.replayQueued` (session) both consume `QUEUED` records; a LINK landing mid-drain can deliver the same message to a session *and* create its Plane item (Plane-side dedup makes it idempotent, at worst a visible duplicate). - `lib/router.js:114` derives `externalId` from a millisecond-resolution ISO timestamp (`kind:receivedAt`); two messages in the same millisecond would collide and be deduped into one. The seq-based `signalExternalId` fallback in `lib/inbox.js` is never reached because the router always sets `external_id`. Merging on green CI per the landing contract. Downstream: `.forgejo/workflows/dispatcher-image-build.yaml` triggers on `push`/`pull_request` to **`live` only**, so this epic-branch merge builds no image — no artifact expected from this landing.
HOMELAB-1685: merge epic/LD4 (LD4.3 el-director) into the intake branch
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 2m10s
b5a806a1a2
#569 (el-director) landed on epic/LD4 first and made this branch unmergeable:
both PRs appended to the same shared files. Resolution, file by file:

- cmd/dispatcher/app.go: union of the import block, the Options seams, the App
  fields and the New() arm-construction blocks. buildRunners needed no edit --
  the two runner registrations merged cleanly (intake at :645, comms :672,
  director :686).
- config/config.go: union of the Arm* consts, the Arms struct fields, and the
  Config.String() summary (one format string, both arms).
- config/load.go: union of the intake and director validation blocks.
- config/example.go: union of the arms keys.
- identity/example-registry.yaml auto-merged; both roles survive (intake and
  el-director).
- identity count assertions recomputed against the MERGED artifact, not taken
  from either side: the example now describes 8 roles (6 active + scout +
  el-director), 35 entries (7x4 + sendrast 4 + el-director 3) and 9 chat
  accounts. `len(roles) != 7` was shared context in the conflict hunk (both
  PRs happened to assert 7) and had to be corrected to 8 separately.

Verified in the merged tree before committing: go build ./... , go vet ./... ,
go test -race -count=1 ./... (20 packages ok) and node --test (23/23).
Author
Owner

VERDICT: APPROVED (re-posted for head b5a806a, the epic-integration merge commit)

#569 (LD4.3 el-director) landed on epic/LD4 first and made this branch unmergeable — both PRs appended to the same shared files. I resolved the integration merge on the PR branch; CI run #1666 on the new head is fully green (dispatcher-test 2m10s, alert-receiver, receiver, helm-validate). The original verdict is unchanged and still stands for the intake diff (I re-derived it against b5a806a); no blocker was opened and none is open.

What the merge resolution did

Unions everywhere the two PRs appended to the same place — no side was preferred:

  • cmd/dispatcher/app.go: import block, Options seams, App fields, the two arm-construction blocks in New(). buildRunners needed no edit: the runners merged cleanly and both survive (intake now at :645, comms :672, director :686).
  • config/config.go: ArmIntake/ArmDirector/ArmComms consts, the Arms struct fields, and one Config.String() format string carrying both arm families.
  • config/load.go: intake validation block + director validation block, both retained.
  • config/example.go: intake: false + director/comms/directorArchive keys.
  • identity/example-registry.yaml auto-merged: both intake (4 surfaces) and el-director (3 surfaces, no forgejo per OQ-7) are present — counted, not assumed.

The identity count trap — recomputed against the merged artifact

The merged example describes 8 roles (6 active: architect, manager, builder-core, builder-elevated, reviewer, intake + 2 dormant: scout, el-director), 35 entries (7×4 surfaces = 28, + builder-core/sendrast ×4, + el-director's 3 token-bearing surfaces) and 9 chat accounts (one per role instance).

len(roles) != 7 → 8, Identities() != 31/32 → 35 (both call sites), ChatAccounts() != 8 → 9. Note the trap fired for real: if len(roles) != 7 was shared context in the conflict hunk (both PRs happened to assert 7), so git kept it untouched and the suite went red until it was corrected separately — exactly the "recompute, don't guess" case.

Verified on the merged tree BEFORE committing

go build ./... clean · go vet ./... clean · go test -race -count=1 ./... → 20 packages ok · node --test test/bridge.test.js → 23/23 pass. gofmt -l clean on cmd/config/identity. git diff origin/epic/LD4 HEAD adds only this PR's 36 files — nothing from el-director was lost.

Findings carried forward from the original verdict

Unchanged, none blocking: C1 intake.Sweeper.ScanDecisions is never invoked (Run only calls Sweep; app.go:645 wires only a.sweeper.Run), so R-04 nudges cannot fire at runtime and the app.go:657-658 / config.go:470 comments overstate it — follow-up needed. C2 the "byte for byte" wire-format claim is inaccurate (Node …09:00:00.000Z vs Go …09:00:00Z; JSON-compatible, Go encoder is a test-only artifact). C3 the bridge's O1 labels are best-effort (a label-endpoint failure degrades to no labels). C4 context.author: plane costs 3 Plane GETs per dispatch, not the stated one.

Merging now on green CI. GitOps continuation: dispatcher-image-build.yaml triggers on push/pull_request to live only, so this epic-branch merge produces no image artifact.

VERDICT: APPROVED (re-posted for head `b5a806a`, the epic-integration merge commit) #569 (LD4.3 el-director) landed on `epic/LD4` first and made this branch unmergeable — both PRs appended to the same shared files. I resolved the integration merge on the PR branch; CI run #1666 on the new head is fully green (dispatcher-test 2m10s, alert-receiver, receiver, helm-validate). The original verdict is unchanged and still stands for the intake diff (I re-derived it against `b5a806a`); no blocker was opened and none is open. ## What the merge resolution did Unions everywhere the two PRs appended to the same place — no side was preferred: - `cmd/dispatcher/app.go`: import block, `Options` seams, `App` fields, the two arm-construction blocks in `New()`. `buildRunners` needed no edit: the runners merged cleanly and **both survive** (`intake` now at `:645`, `comms` `:672`, `director` `:686`). - `config/config.go`: `ArmIntake`/`ArmDirector`/`ArmComms` consts, the `Arms` struct fields, and one `Config.String()` format string carrying both arm families. - `config/load.go`: intake validation block + director validation block, both retained. - `config/example.go`: `intake: false` + `director/comms/directorArchive` keys. - `identity/example-registry.yaml` auto-merged: both `intake` (4 surfaces) and `el-director` (3 surfaces, no forgejo per OQ-7) are present — counted, not assumed. ## The identity count trap — recomputed against the merged artifact The merged example describes **8 roles** (6 active: architect, manager, builder-core, builder-elevated, reviewer, **intake** + 2 dormant: **scout**, **el-director**), **35 entries** (7×4 surfaces = 28, + builder-core/sendrast ×4, + el-director's 3 token-bearing surfaces) and **9 chat accounts** (one per role instance). `len(roles) != 7 → 8`, `Identities() != 31/32 → 35` (both call sites), `ChatAccounts() != 8 → 9`. Note the trap fired for real: `if len(roles) != 7` was *shared context* in the conflict hunk (both PRs happened to assert 7), so git kept it untouched and the suite went red until it was corrected separately — exactly the "recompute, don't guess" case. ## Verified on the merged tree BEFORE committing `go build ./...` clean · `go vet ./...` clean · `go test -race -count=1 ./...` → **20 packages ok** · `node --test test/bridge.test.js` → **23/23 pass**. `gofmt -l` clean on cmd/config/identity. `git diff origin/epic/LD4 HEAD` adds only this PR's 36 files — nothing from el-director was lost. ## Findings carried forward from the original verdict Unchanged, none blocking: **C1** `intake.Sweeper.ScanDecisions` is never invoked (`Run` only calls `Sweep`; `app.go:645` wires only `a.sweeper.Run`), so R-04 nudges cannot fire at runtime and the `app.go:657-658` / `config.go:470` comments overstate it — follow-up needed. **C2** the "byte for byte" wire-format claim is inaccurate (Node `…09:00:00.000Z` vs Go `…09:00:00Z`; JSON-compatible, Go encoder is a test-only artifact). **C3** the bridge's O1 labels are best-effort (a label-endpoint failure degrades to no labels). **C4** `context.author: plane` costs 3 Plane GETs per dispatch, not the stated one. Merging now on green CI. GitOps continuation: `dispatcher-image-build.yaml` triggers on `push`/`pull_request` to **`live` only**, so this epic-branch merge produces no image artifact.
aaron merged commit 1519dd2a7b into epic/LD4 2026-10-02 01:25:35 +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!568
No description provided.