HOMELAB-1687: feat(ld4): El Director v1 — persona, warranted memory, veto, hand-off, RC DM primary #569
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!569
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1687-el-director"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements LD4.3 · El Director v1 to
design-LD4.3-el-director.md(with thecorrection banner applied: RocketChat IS deployed, ns
chatRunning, R-8 void) andto the HOMELAB-1687 orchestrator rulings (OQ-1/2/3/5/6/7, R-3, R-6).
Do not merge. Builder worker only: fixture-first code, no live mutations.
What lands
events/meeting.completed+memory.candidatecontrol-event vocabulary (LD4.2 owns the producer)rcbridge/EnsureDM/POST /api/v1/im.create— the only transport changepersona/el-director.yaml(content, not config) + a strict loader, a renderer, andPersonaSourceso ONEsha256:pin covers persona + lorecomms/director/HTTPPlaneSinkcmd/dispatcher/directorandcommsarms (non-fatal,rcTopologyposture), wired inbuildRunnersconfig/+identity/el-directoridentity, and the runtime mount pathsrole-lore/el-director/NEVER-list enforcement points (contract §2 — the list is enforced where the Director is NOT)
config.TemplateMaphas no Director entry; the sandbox template is the only build surface, andspawnrefuses a class with no template.director.DefaultTemplatesis untouched, so a Director run cannot be spawned with a build template todayidentity/example-registry.yaml:el-directorbinds no forgejo surface and no tokenRef at all (OQ-7). The role is deliberately DORMANT, not inactiveRoles: an active role must bind every surface with a tokenRef, so activating it would require the Forgejo token OQ-7 forbids. Merge is the landing worker's, gated by the landing lock (HOMELAB-1679)director.PlaneSinkis exactly two methods (CreateItem,Comment) andboundary_test.gowalks the package's own syntax tree to fail on any method named spawn/claim/merge/delete/transition/apply. A hand-off is a Plane item; the dispatcher's watch picks it up as ordinary workdirectormemory loader: an unresolvable warrant excludes the record from the active set with a fault naming file + line;VetoService.Filerefuses a warrant-less veto before writing anythingVeto.Validate+VetoService.File(emptynamed_gap, missing/malformed/unresolvable warrant, unknown correction class, self-convened artifact)ld-el-directorchat account from OpenBao via ESO, andHTTPPlaneSinkrenders its key as[redacted]in every fmt verb (tested)The one measurement rather than boundary:
veto_rate = vetoes / (vetoes + ratifications),with
veto_refusalscounted separately andveto_overturn_rateas the calibrationinput. OQ-5's provisional band
0.05 ≤ rate ≤ 0.35per quarter is code(
director.QuarterBand), with a proposal floor so a zero rate reads unmeasured,never "in band" (baselines #8: unmeasured ≠ zero).
The veto flow
A
stop/re-scopewhose release fails is reported as a partial outcome (therecord landed, the stop did not) rather than as a refusal or a success.
VetoService.Filerefuses BEFORE writing when a stopping class has no releaserwired: a comment claiming an action that did not happen is the one thing a veto
record must not be.
Signal demotion sequencing — the ladder is NOT flipped
Demotion is a re-ordering, never a deletion (design §2.7). What this PR does
and does not do:
comms.Ladderclassifies every human-directed message.attention.pushand the 24h
nudge.r04go to RC DM only.escalation.p1dual-posts (RC DM +Signal), and so does the 72h ACT nudge when the caller marks it. A failed probe
degrades the ladder, emits exactly one
fallback.noticeper episode, and thedegraded ladder stops calling RocketChat entirely (no retry storm).
pi-signal-bridgeNode service,pi-alert-receiver'sBridgeNotifier, R-04's implementation, and thesignal-modeskill. The Signalladder order is unchanged in every live path.
one Signal egress is the
pi-signal-bridgesystemd user unit on the mgmt VMreached over the pi-intercom broker's socket (HOMELAB-1465); a dispatcher pod
cannot reach it. So
comms.UnwiredSignalClientFAILS with that reason, andconfig: director.signalFallback: trueis refused withFaultUnwiredArm.A stub would report pages that never arrived.
the sequencing precondition: a live P1 observed in the RC DM — RC mobile push
proven on the operator's phone. Until that is seen, the Signal leg stays primary
where it is primary today, and this PR changes no live path.
Deviations from the design brief (all deliberate, all reviewable)
dispatcher/persona/el-director.yamlanddispatcher/role-lore/el-director/…rather than
lab-director/persona/…. Reason:.forgejo/workflows/ci.yaml:80mounts only
lab-director/dispatcherinto the Go test container, so acanonical persona outside the module could never be loaded, validated or pinned
by
go test -race ./..., and an artifact no test reads is an artifact thatdrifts. OQ-1's substance is honoured (the persona is pinned role-lore content,
PR-reviewed, next to the identity registry it must check against); only the repo
path differs. Overruling this is a 1-line ci.yaml mount plus a path constant.
director.userEnv/director.tokenEnvreplace the brief'sdmUserEnv/dmTokenEnv:load.go's credential scan refuses a credential-shapedkey that is not on its exact-match allowlist, and
dmTokenEnvnormalises todmtokenenv, which is not on it. The names now match therc:block's.arms.directorArchiveis added and refused when true (FaultUnwiredArm):the live warrant ARCHIVE reader does not exist, so the loader validates warrant
shapes and reports that it did not check existence — rather than the design's
"where the archive is mounted" being silently assumed.
director.UnwiredConvenerstands in for the roundtable convene: the ordinaryspawn path is keyed on a Plane ticket (
spawn.Spec.TicketID,compiler.Spec.Ticket) and a meeting has none, and no meeting-scoped contextsource exists. A complete
meeting.completedtherefore fails loudly instead ofreporting a roundtable that never sat.
DecisionSinkPlane writer is the documented LD2.3 follow-up, so the arm sayswhat it parsed and states that the durable ingest is unwired. (R-7's escalation
question is implemented in full.)
compiler/roles.gochange: the persona joinsrole-lorethroughpersona.PersonaSource, soCanonicalSectionsis untouched.rcbridgechat projection ofmeeting.completedis not added:project.gorenders an unrecognised kind generically by contract ("anything elseis rendered generically under its own name"), and nothing projects the meeting
event in v1.
Test plan → the ticket's three Verify items
go vet ./...andgo test -race ./...are green module-wide; not one test opens asocket (the Plane and RocketChat transports are stub
RoundTrippers).Verify 1 — "a Director hand-off request routes to the dispatcher, never executes itself"
director.TestHandOffEmitsExactlyOneItemAndNothingElse(AC-1): a hand-off producesexactly one
create_itemcall and the fixture's whole call log containsnothing else — the fixture has
spawn/claim/merge/deletemethods sothe zero is an observation, not an argument.
director.TestDirectorExposesNoForbiddenSurface+TestPlaneSinkIsExactlyTwoMethodsTestStopReleaserIsTheOnlyClaimShapedSurface: the structural half, over thepackage's own syntax tree.
cmd/dispatcher.TestDirectionDMMintsOneInboxItemAndAcknowledgesIt(OQ-6) assertsone item, one ack naming it, and zero spawn/claim/merge/delete.
Verify 2 — "a veto without a warrant is refused"
director.TestVetoWithoutAWarrantIsRefusedAndWritesNothing(AC-2): six refusalshapes (empty named gap, missing warrant, malformed ref, unknown class,
self-convened artifact, no item) — each asserted for the sentinel, the teaching
sentence, zero calls of any kind, and the refusal counter moving while the veto
counter does not.
director.TestVetoWithAnUnresolvableWarrantIsRefused,TestWellFormedVetoWritesExactlyOneStructuredComment(one
ld.veto/v1comment, no item created),TestStopVetoReleasesTheClaim(R-6),TestStoppingVetoWithoutAReleaserIsRefusedBeforeWriting,TestVetoRecordedButReleaseFailedIsReported.Verify 3 — "Signal fires only on fallback paths"
comms.TestAttentionPushAndSoftNudgeNeverTouchSignal(AC-3): zero Signal calls.comms.TestEscalationP1DualPostsExactlyOnce,TestHardNudgeDualPostsAndSoftNudgeDoesNot.comms.TestFailingProbeProducesOneNoticeAndNoRetryStorm: exactly onefallback.notice, every message still delivered, and zero RocketChat callswhile degraded.
comms.TestFailedPrimaryAttemptDegradesAndDelivers,TestRecoveryRestoresThePrimaryAndResetsTheNotice(one notice per episode),
TestUnwiredSignalClientFailsLoudly.AC-4/5/6/7 (the rest of the brief's §5 map)
persona: canonical load + registry cross-check; account-not-derived refusedwith a line; unknown key refused; NEVER set that adds, removes or re-orders a
contract §2 line refused; every enumerated field's vocabulary closed; the rendered
block compiled into the
role-loresection through a realcompiler.Compilewithre-derived pins (a changed tone re-derives a different digest).
directormemory: an unresolvablerc-dm:ref excludes the record and faultsat file + line (asserted at line 9 — the
ref); a quarantined record is composedinto no package; a
contradicts-non-empty record escalates instead of applying;the maturity ladder (two comments on one ticket count as ONE provenance); a
conversation-staterecord carries no warrants and is pinned nowhere.directormeeting: a missing transcript opens exactly ONE decision-queue itemand convenes nothing; a complete event convenes exactly once; a malformed event
writes nothing.
directorcounters:veto_rate = N/(N+M), refusals separate, overturn rate,band verdicts (unmeasured / too-low / in-band / too-high) and the telemetry map.
message id, R-7 escalation question, and a refused chat delivery not stopping the
Plane write.
What remains for the landing worker / operator
wanted on the
rcbridgetransport change (im.createis the first new endpointsince LD2.3) and on the identity-registry dormancy decision (OQ-7).
Each needs the operator: (a) launch the Director arm with the
ld-el-directoraccount provisioned; (b)
director.signalFallbackstays false until a Signalegress reachable from the cluster exists; (c) a live P1 in the RC DM before the
ladder flips.
director:block is already inconfig/example.go; the prodConfigMap change is a separate PR in
prod(arms OFF — inert keys only), and theld-el-directorOpenBao path + ExternalSecret is an operator-reviewed secretschange that this PR deliberately does not contain.
meeting-scoped context source (so a meeting can be convened), the warrant archive
reader, and
rcbridge.DecisionSink's Plane writer.New package persona/: el-director.yaml (the canonical persona, CONTENT not config) plus a strict loader that refuses what the contract forbids: * an unknown key (KnownFields) or an out-of-vocabulary tone/behavior/tier value — a silently dropped setting behaves like one that never existed; * identity.account that is not the name identity.AccountNameFor derives, or that the committed registry does not bind on Plane and RocketChat (§8); * boundaries.never that is not EXACTLY the contract §2 set for the role, in that order — adding a prohibition is refused (a standing rule is a mechanism, a measurement or a deletion, §5) and removing one is refused. Persona.Render() emits the fixed block and PersonaSource wraps the role-lore corpus source so ONE sha256 pin covers persona + lore: a changed tone re-derives a different digest, which is the point of pinning it. The persona carries tone, never authority — enforcement lives where the Director is not (design §2.4), which is the structural answer to risk R-1.New package comms/: MessageClass (attention.push, nudge.r04, escalation.p1, fallback.notice, director.reply, direction-ack, refusal), Message, Health, the Ladder, the SignalClient seam and FixtureSignalClient, and the probe loop the arm runs. The ladder is where contract §7 ('Signal = outage-fallback + attention-push only') becomes code, with two anti-storm properties that are the reason it is a package and not three if-statements in an arm: * ONE fallback.notice per degradation episode (edge-latched, emitted by the first message that actually needs the fallback — a probe failure alone pages nobody); * a degraded ladder never touches RocketChat, so an outage costs one failed probe per interval rather than one timeout per message. escalation.p1 dual-posts unconditionally; the 24h soft nudge never does; the 72h ACT nudge does because the caller marks it, not because of its class. Deliver returns what actually happened (route, room, signal calls, notice, degraded) and reports a failed attention leg rather than claiming delivery. rccbridge stays the transport and holds the credential: comms is handed a DMClient (EnsureDM+Post) and a SignalClient, so 'the ladder can only post' is structural. Tests use the committed chat fixture: no socket is opened.New package director/: the El Director's warranted machinery, fixture-first. * warrants: a closed source vocabulary, a per-class ref grammar, and a Resolver seam. Resolvability is CHECKED, not assumed — a record whose citation does not resolve is excluded from the active set with a fault naming file + line (the anti-hallucination boundary, AC-5). * memory: the git-backed record store (preference / lore / conversation-state), the maturity ladder (established needs two INDEPENDENT warrants — provenance, not count; canonical needs an operator-ruling on top), the 90-day TTL, contradiction = ESCALATE not coin-flip, and Compose() which renders only the active set. A quarantined or contradicted record is composed into no package. * veto: named gap + resolvable warrant, or refused with a teaching message and NOTHING written to Plane (AC-2). A stop veto releases the claim and leaves the rest to the TTL reaper (R-6: no sentinel scope). Recording one veto is exactly one ld.veto/v1 comment. * hand-off: exactly ONE Plane item, zero spawn/claim calls (AC-1), asserted from the fixture's whole call log; plus the OQ-6 direction intake that mints an inbox item quoting the operator's own words. * meeting: a complete meeting.completed convenes the roundtable once; a gap opens exactly ONE decision-queue item naming every missing artifact and convenes nothing (AC-6). * counters: veto_rate = N/(N+M) with refusals counted SEPARATELY, the overturn rate as the calibration input, and the OQ-5 band (0.05-0.35/quarter, with a proposal floor so a zero rate reads 'unmeasured' rather than 'in band') (AC-7). The package has no spawn, claim, merge, delete or transition surface at all — PlaneSink is exactly CreateItem + Comment — and boundary_test.go asserts that over the package's own syntax tree. The live Plane writer is HTTPPlaneSink (item create + comment, redacting String, stub-transport tests: no socket is opened anywhere in this package).Wires El Director v1 into the process as two NON-FATAL arms (the rcTopology posture: a chat outage is an observability outage, not a dispatch outage): * director the DM listener. Four intents and no fifth: a !decision marker in a DM maps to no ticket, so the R-7 rail replies with ONE escalation question (and names the item when the operator scoped it with ticket=<KEY>); a meeting-done DM becomes the manual meeting.completed trigger; an utterance taken as direction mints an inbox item and is acknowledged with its identifier (OQ-6); conversation is recorded, not answered (the model surface is not wired in v1, and a template reply would be worse than silence). * comms the ladder's health probe, one observation per interval, so 'is RocketChat up?' is answered once per probe instead of once per message. Config: a director block (personaPath, memoryRoot, userEnv/tokenEnv for the SECOND chat account, operator, signalFallback, fallbackProbeInterval, pollInterval), arms.director/arms.comms/arms.directorArchive, a Secrets pair for the el-director account, and the example document. The keys are named userEnv/tokenEnv rather than the design's dmUserEnv/dmTokenEnv because load.go's credential scan refuses a credential-shaped key that is not on its exact-match allowlist and dmTokenEnv is not. Two refusals are deliberate and loud, not stubs: * director.signalFallback: true is refused (FaultUnwiredArm). The fleet has exactly one Signal egress — the pi-signal-bridge systemd USER unit on the mgmt VM, reached over the pi-intercom socket — and a dispatcher pod cannot reach it. comms.UnwiredSignalClient fails with that reason instead of reporting pages that never arrive. * arms.directorArchive: true is refused for the same reason: the warrant archive reader does not exist yet, so the loader validates warrant SHAPES and says so (a nil Resolver). buildDirector fails loud on a persona that does not validate or a corpus that cannot be read, and REPORTS memory faults rather than dying on them: one record whose warrant does not resolve must not take the Director off the air. The veto stop is a claim release via the ordinary Plane writer (R-6) — no sentinel scope, and the arm cannot stop a sandbox it does not own. Fixture-driven arm tests use the committed persona, registry and chat fixture: no socket is opened.Landing review verdict — APPROVE (no blockers)
Reviewed at head
e4ea1746da92a89ebb3cb84d15a45bba441a8d5e(fresh context, primary sources only: PR diff, ticket HOMELAB-1687 + its Plane comments,design-LD4.3-el-director.md+ correction banner, contract v0.9.1 §2/§5/§8/§10/§11). Not handed the implementer's summary as a brief.Evidence re-derived, not attested:
go vet ./...clean andgo test -race ./...green module-wide in the PR worktree ate4ea174(17/17 packagesok, exit 0). Forgejo CI run #1662 one4ea174green (dispatcher-test, alert-receiver-test, receiver-test, helm-validate). Merge-base withepic/LD4=cd6db02= the base → clean merge.Acceptance criteria
TestHandOffEmitsExactlyOneItemAndNothingElsereads the fixture's whole call log (Methods() == [create_item]) — andFixtureAgentdeliberately carriesSpawn/Claim/Merge/Delete, so the zero is an observation, not an argument. Structural half holds:PlaneSinkis exactly{Comment, CreateItem}(reflection-pinned),boundary_test.gowalks the package's own syntax tree for forbidden verbs exempting onlyFixture*receivers and fails if it finds no fixture surfaces to exempt (the guard cannot silently pass),ItemDraftcarries no state/assignee/label, no file indirector/importsspawn/claims/MCP, andconfig.DefaultTemplatesis untouched (class→template A–E only). v1 runs the Director as an arm, so there is no Director sandbox template to reach.named_gap→ReasonVetoUnnamedGapwith the teaching sentence; missing/malformed warrant →ReasonVetoUnwarrantedfromValidate, unresolvable warrant → the same class fromFile'sResolver; self-convened artifact →ReasonVetoSelfConvened; stopping class with no releaser refused before writing. Each case assertslen(agent.Calls()) == 0and that refusals moveRefusalswhileVetoesstays 0.attention.push+ 24hnudge.r04→ zero Signal calls;escalation.p1→ exactly one dual-post; failing probe → exactly onefallback.noticeper episode with every message still delivered and zero RocketChat calls while degraded (no retry storm). The refusal is fail-loud, not silent:UnwiredSignalClientwrapsErrSignalUnwirednamingpi-signal-bridge, anddirector.signalFallback: trueis refused at config load withFaultUnwiredArm(fault-tested). The ladder is not flipped, which is correct per the R-4 dual-post gate.role-lorethrough a realcompiler.Compilewith the pin re-derived.rc-dm:ref excludes the record and the fault names file + line (asserted at therefline); quarantined/conversation-staterecords composed nowhere;contradictsescalates;establishedneeds two independent sources.UnwiredConvenerfails loudly with the ticket-keyed-spawn reason. Judged acceptable for v1: it is fail-loud, disclosed in the body and the README, and the missing piece (meeting-scoped context source) belongs to LD4.2's producer — it does not paper over the AC's unit half.veto_rate = N/(N+M)with refusals separate, overturn rate, and the0.05–0.35quarter band resolvingunmeasuredbelow the proposal floor (baselines #8 honoured).Claim honesty: deviations 1–4 all match the diff. Deviation 1 is verifiable in-tree —
.forgejo/workflows/ci.yamlmounts only$PWD/lab-director/dispatcherinto thegolang:1.24container, so an out-of-module persona genuinely could not be validated or pinned. OQ-7 dormancy is registry-test-asserted (noforgejoentry; not inactiveRoles).House rules: the new tests open no socket (RoundTripper stubs + fixtures); the only
net.Listen/httptestuses in the module are pre-existing (compiler/source_test.go,cmd/dispatcher/{app,check}_test.go) and untouched here.Concerns (non-blocking, please track)
C1 — the veto has no intake.
directorServices.vetoesis constructed inbuildDirector(and in the arm fixture) but never called by the arm: the four classified intents are decision-marker / meeting-done / direction / conversation — none is a veto. So in v1 no live surface can raise a veto;VetoServiceis reachable only from tests, and AC-2's live half ("post a warrant-less veto in RC DM") has no rail. AC-2's stated criteria are met, the omission is fail-closed (no veto beats a false veto), and the live half is the operator's anyway — but it is not in the deviations list, while the body's veto-flow diagram ("DM / item --> Veto{…}", "returns the refusal to the DM") implies an intake that does not exist. Suggest a follow-up ticket (DM veto intake, design §2.5 JSON shape) + a deviations line.C2 — latent Plane addressing mismatch on that same path.
Veto.Itemis documented as "a key such asHOMELAB-1686" and flows toPlaneSink.Comment→POST /issues/{key}/comments/and (R-6) toclaims.APIWriter.Transition→GET/PATCH /issues/{key}/. This codebase addresses Plane issues by UUID;claimsderives the human key fromsequence_id("the only way to get a human ticket key" —claims/api.go:528). Both writes will likely fail on a human key once the veto is wired. Nothing breaks today (unreachable). Recommend resolving key→UUID first, or documentingItemas a UUID. I did not probe the live Plane API (no mutations allowed), so this is a flag, not a proven failure.Suggestions
gofmt -lflagspersona/persona.go(two struct-literal alignment diffs, whitespace only). There is no gofmt gate (lint.yaml = terraform/yamllint/shellcheck; ci.yaml = vet+test), so CI is green — but the module is otherwise gofmt-clean.claimsStopReleaser.Releaseinterpolates the veto reason (which embeds the DM-suppliednamed_gap) into Plane comment HTML unescaped — escape it, or use a fixed sentence.arms_director.go reply()logs "could not be delivered on either leg" on any ladder error, but a failedescalation.p1attention leg still hasDelivery.Route == rc-dm. The ladder already reports the distinction; the log should use it.el-directorbinds no forgejo surface and no tokenRef at all" — the entry does carry two tokenRefs (plane, rocketchat); only the Forgejo one is absent. The substantive claim (no merge surface) is correct.Integration / downstream
epic/LD4is atcd6db02in Forgejo — this PR's base and merge-base, so the merge is clean. #567 and #568 are both still OPEN (the brief's premise that #567 was merged is stale); no conflict either way. The dispatcher image build publishes only fromlive, so merging toepic/LD4publishes nothing.prod#871 stays operator-held; nothing downstream to watch from this merge.Result: no blocker findings open. Merging.