HOMELAB-1687: feat(ld4): El Director v1 — persona, warranted memory, veto, hand-off, RC DM primary #569

Merged
aaron merged 10 commits from plane/HOMELAB-1687-el-director into epic/LD4 2026-10-02 01:07:09 +00:00
Owner

Implements LD4.3 · El Director v1 to design-LD4.3-el-director.md (with the
correction banner applied: RocketChat IS deployed, ns chat Running, R-8 void) and
to 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

Area What it is
events/ meeting.completed + memory.candidate control-event vocabulary (LD4.2 owns the producer)
rcbridge/ the DM primitive: EnsureDM / POST /api/v1/im.create — the only transport change
persona/ el-director.yaml (content, not config) + a strict loader, a renderer, and PersonaSource so ONE sha256: pin covers persona + lore
comms/ the message-class ladder: RC-DM primary, Signal fallback + attention-push, per-episode fallback notice, health probe
director/ warrants, the git-backed operator-memory store, the veto disposition, hand-off emission, the meeting-done handler, veto counters, HTTPPlaneSink
cmd/dispatcher/ the director and comms arms (non-fatal, rcTopology posture), wired in buildRunners
config/ + identity/ the Director's keys, the el-director identity, and the runtime mount paths
role-lore/el-director/ the corpus seed — deliberately EMPTY, with the schema and the O6-analog TODO

NEVER-list enforcement points (contract §2 — the list is enforced where the Director is NOT)

NEVER Enforcement point Class
implements config.TemplateMap has no Director entry; the sandbox template is the only build surface, and spawn refuses a class with no template. director.DefaultTemplates is untouched, so a Director run cannot be spawned with a build template today boundary
merges identity/example-registry.yaml: el-director binds no forgejo surface and no tokenRef at all (OQ-7). The role is deliberately DORMANT, not in activeRoles: 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) boundary
hand-dispatches director.PlaneSink is exactly two methods (CreateItem, Comment) and boundary_test.go walks 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 work boundary
attributes intent to a human without a citation director memory loader: an unresolvable warrant excludes the record from the active set with a fault naming file + line; VetoService.File refuses a warrant-less veto before writing anything boundary
vetoes without a warrant Veto.Validate + VetoService.File (empty named_gap, missing/malformed/unresolvable warrant, unknown correction class, self-convened artifact) boundary
holds credentials beyond Plane / Forgejo(read) / Outline the Director sandbox is not wired at all in v1 (the arm runs in-process); the arm holds the ld-el-director chat account from OpenBao via ESO, and HTTPPlaneSink renders its key as [redacted] in every fmt verb (tested) boundary

The one measurement rather than boundary: veto_rate = vetoes / (vetoes + ratifications),
with veto_refusals counted separately and veto_overturn_rate as the calibration
input. OQ-5's provisional band 0.05 ≤ rate ≤ 0.35 per 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

DM / item --> Veto{Item, NamedGap, Warrant, CorrectionClass, TasteCandidate}
                 |
        Validate + resolve the warrant
        |refused --> teaching message back to the DM; NOTHING written to Plane;
        |            veto_refusals += 1
        `accepted --> exactly ONE `ld.veto/v1` structured comment on the item
                      correction_class re-plan  -> annotate only
                                        re-scope -> returned-to-intake marker
                                        stop     -> claim RELEASE (R-6), and the
                                                    TTL reaper completes it;
                                                    no sentinel scope is added
                      vetoes += 1, by class

A stop/re-scope whose release fails is reported as a partial outcome (the
record landed, the stop did not) rather than as a refusal or a success.
VetoService.File refuses BEFORE writing when a stopping class has no releaser
wired: 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:

  1. Touched: comms.Ladder classifies every human-directed message. attention.push
    and the 24h nudge.r04 go to RC DM only. escalation.p1 dual-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.notice per episode, and the
    degraded ladder stops calling RocketChat entirely (no retry storm).
  2. Not touched: the pi-signal-bridge Node service, pi-alert-receiver's
    BridgeNotifier, R-04's implementation, and the signal-mode skill. The Signal
    ladder order is unchanged in every live path.
  3. Blocked, and said out loud: the ladder's Signal leg is unwired. The fleet's
    one Signal egress is the pi-signal-bridge systemd user unit on the mgmt VM
    reached over the pi-intercom broker's socket (HOMELAB-1465); a dispatcher pod
    cannot reach it. So comms.UnwiredSignalClient FAILS with that reason, and
    config: director.signalFallback: true is refused with FaultUnwiredArm.
    A stub would report pages that never arrived.
  4. Flipping the ladder (making RC DM primary for the alert rail) still requires
    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)

  1. The canonical artifacts live inside the dispatcher module —
    dispatcher/persona/el-director.yaml and dispatcher/role-lore/el-director/…
    rather than lab-director/persona/…. Reason: .forgejo/workflows/ci.yaml:80
    mounts only lab-director/dispatcher into the Go test container, so a
    canonical persona outside the module could never be loaded, validated or pinned
    by go test -race ./..., and an artifact no test reads is an artifact that
    drifts. 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.
  2. director.userEnv / director.tokenEnv replace the brief's
    dmUserEnv/dmTokenEnv: load.go's credential scan refuses a credential-shaped
    key that is not on its exact-match allowlist, and dmTokenEnv normalises to
    dmtokenenv, which is not on it. The names now match the rc: block's.
  3. arms.directorArchive is 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.
  4. director.UnwiredConvener stands in for the roundtable convene: the ordinary
    spawn path is keyed on a Plane ticket (spawn.Spec.TicketID,
    compiler.Spec.Ticket) and a meeting has none, and no meeting-scoped context
    source exists. A complete meeting.completed therefore fails loudly instead of
    reporting a roundtable that never sat.
  5. The Director's DM decision marker is answered, not ingested durably: the
    DecisionSink Plane writer is the documented LD2.3 follow-up, so the arm says
    what it parsed and states that the durable ingest is unwired. (R-7's escalation
    question is implemented in full.)
  6. No ninth context section and no compiler/roles.go change: the persona joins
    role-lore through persona.PersonaSource, so CanonicalSections is untouched.
  7. The rcbridge chat projection of meeting.completed is not added:
    project.go renders an unrecognised kind generically by contract ("anything else
    is rendered generically under its own name"), and nothing projects the meeting
    event in v1.

Test plan → the ticket's three Verify items

go vet ./... and go test -race ./... are green module-wide; not one test opens a
socket
(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 produces
    exactly one create_item call and the fixture's whole call log contains
    nothing else — the fixture has spawn/claim/merge/delete methods so
    the zero is an observation, not an argument.
  • director.TestDirectorExposesNoForbiddenSurface + TestPlaneSinkIsExactlyTwoMethods
    • TestStopReleaserIsTheOnlyClaimShapedSurface: the structural half, over the
      package's own syntax tree.
  • cmd/dispatcher.TestDirectionDMMintsOneInboxItemAndAcknowledgesIt (OQ-6) asserts
    one 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 refusal
    shapes (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/v1 comment, 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 one
    fallback.notice, every message still delivered, and zero RocketChat calls
    while degraded.
  • comms.TestFailedPrimaryAttemptDegradesAndDelivers, TestRecoveryRestoresThePrimaryAndResetsTheNotice
    (one notice per episode), TestUnwiredSignalClientFailsLoudly.

AC-4/5/6/7 (the rest of the brief's §5 map)

  • AC-4 persona: canonical load + registry cross-check; account-not-derived refused
    with 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-lore section through a real compiler.Compile with
    re-derived pins (a changed tone re-derives a different digest).
  • AC-5 director memory: an unresolvable rc-dm: ref excludes the record and faults
    at file + line (asserted at line 9 — the ref); a quarantined record is composed
    into 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-state record carries no warrants and is pinned nowhere.
  • AC-6 director meeting: a missing transcript opens exactly ONE decision-queue item
    and convenes nothing; a complete event convenes exactly once; a malformed event
    writes nothing.
  • AC-7 director counters: veto_rate = N/(N+M), refusals separate, overturn rate,
    band verdicts (unmeasured / too-low / in-band / too-high) and the telemetry map.
  • Plus the arm itself: four intents classified, own messages ignored, dedupe by
    message id, R-7 escalation question, and a refused chat delivery not stopping the
    Plane write.

What remains for the landing worker / operator

  • Not merged, and not to be merged by CI convention: dangerous-adjacent review
    wanted on the rcbridge transport change (im.create is the first new endpoint
    since LD2.3) and on the identity-registry dormancy decision (OQ-7).
  • Live halves are NOT verified by this worker (instruction: no live mutations).
    Each needs the operator: (a) launch the Director arm with the ld-el-director
    account provisioned; (b) director.signalFallback stays false until a Signal
    egress reachable from the cluster exists; (c) a live P1 in the RC DM before the
    ladder flips.
  • Deployment: the director: block is already in config/example.go; the prod
    ConfigMap change is a separate PR in prod (arms OFF — inert keys only), and the
    ld-el-director OpenBao path + ExternalSecret is an operator-reviewed secrets
    change that this PR deliberately does not contain.
  • Follow-ups named in the code and the README: a reachable Signal egress, a
    meeting-scoped context source (so a meeting can be convened), the warrant archive
    reader, and rcbridge.DecisionSink's Plane writer.
Implements **LD4.3 · El Director v1** to `design-LD4.3-el-director.md` (with the correction banner applied: RocketChat IS deployed, ns `chat` Running, R-8 void) and to 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 | Area | What it is | |---|---| | `events/` | `meeting.completed` + `memory.candidate` control-event vocabulary (LD4.2 owns the producer) | | `rcbridge/` | the DM primitive: `EnsureDM` / `POST /api/v1/im.create` — the only transport change | | `persona/` | `el-director.yaml` (content, not config) + a strict loader, a renderer, and `PersonaSource` so ONE `sha256:` pin covers persona + lore | | `comms/` | the message-class ladder: RC-DM primary, Signal fallback + attention-push, per-episode fallback notice, health probe | | `director/` | warrants, the git-backed operator-memory store, the veto disposition, hand-off emission, the meeting-done handler, veto counters, `HTTPPlaneSink` | | `cmd/dispatcher/` | the `director` and `comms` arms (non-fatal, `rcTopology` posture), wired in `buildRunners` | | `config/` + `identity/` | the Director's keys, the `el-director` identity, and the runtime mount paths | | `role-lore/el-director/` | the corpus seed — deliberately EMPTY, with the schema and the O6-analog TODO | ## NEVER-list enforcement points (contract §2 — the list is enforced where the Director is NOT) | NEVER | Enforcement point | Class | |---|---|---| | **implements** | `config.TemplateMap` has no Director entry; the sandbox template is the only build surface, and `spawn` refuses a class with no template. `director.DefaultTemplates` is untouched, so a Director run cannot be spawned with a build template today | boundary | | **merges** | `identity/example-registry.yaml`: `el-director` binds **no forgejo surface and no tokenRef at all** (OQ-7). The role is deliberately DORMANT, not in `activeRoles`: 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) | boundary | | **hand-dispatches** | `director.PlaneSink` is exactly **two methods** (`CreateItem`, `Comment`) and `boundary_test.go` walks 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 work | boundary | | **attributes intent to a human without a citation** | `director` memory loader: an unresolvable warrant excludes the record from the active set with a fault naming **file + line**; `VetoService.File` refuses a warrant-less veto before writing anything | boundary | | **vetoes without a warrant** | `Veto.Validate` + `VetoService.File` (empty `named_gap`, missing/malformed/unresolvable warrant, unknown correction class, self-convened artifact) | boundary | | **holds credentials beyond Plane / Forgejo(read) / Outline** | the Director sandbox is not wired at all in v1 (the arm runs in-process); the arm holds the `ld-el-director` chat account from OpenBao via ESO, and `HTTPPlaneSink` renders its key as `[redacted]` in every fmt verb (tested) | boundary | The one **measurement** rather than boundary: `veto_rate = vetoes / (vetoes + ratifications)`, with `veto_refusals` counted separately and `veto_overturn_rate` as the calibration input. OQ-5's provisional band `0.05 ≤ rate ≤ 0.35` per 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 ``` DM / item --> Veto{Item, NamedGap, Warrant, CorrectionClass, TasteCandidate} | Validate + resolve the warrant |refused --> teaching message back to the DM; NOTHING written to Plane; | veto_refusals += 1 `accepted --> exactly ONE `ld.veto/v1` structured comment on the item correction_class re-plan -> annotate only re-scope -> returned-to-intake marker stop -> claim RELEASE (R-6), and the TTL reaper completes it; no sentinel scope is added vetoes += 1, by class ``` A `stop`/`re-scope` whose release fails is reported as a **partial** outcome (the record landed, the stop did not) rather than as a refusal or a success. `VetoService.File` refuses BEFORE writing when a stopping class has no releaser wired: 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: 1. **Touched:** `comms.Ladder` classifies every human-directed message. `attention.push` and the 24h `nudge.r04` go to RC DM only. `escalation.p1` **dual-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.notice` per episode, and the degraded ladder stops calling RocketChat entirely (no retry storm). 2. **Not touched:** the `pi-signal-bridge` Node service, `pi-alert-receiver`'s `BridgeNotifier`, R-04's implementation, and the `signal-mode` skill. The Signal ladder order is unchanged in every live path. 3. **Blocked, and said out loud:** the ladder's Signal leg is **unwired**. The fleet's one Signal egress is the `pi-signal-bridge` systemd **user** unit on the mgmt VM reached over the pi-intercom broker's socket (HOMELAB-1465); a dispatcher pod cannot reach it. So `comms.UnwiredSignalClient` FAILS with that reason, and `config: director.signalFallback: true` is refused with `FaultUnwiredArm`. A stub would report pages that never arrived. 4. **Flipping the ladder** (making RC DM primary for the alert rail) still requires 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) 1. **The canonical artifacts live inside the dispatcher module** — `dispatcher/persona/el-director.yaml` and `dispatcher/role-lore/el-director/…` rather than `lab-director/persona/…`. Reason: `.forgejo/workflows/ci.yaml:80` mounts **only** `lab-director/dispatcher` into the Go test container, so a canonical persona outside the module could never be loaded, validated or pinned by `go test -race ./...`, and an artifact no test reads is an artifact that drifts. **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. 2. **`director.userEnv` / `director.tokenEnv`** replace the brief's `dmUserEnv`/`dmTokenEnv`: `load.go`'s credential scan refuses a credential-shaped key that is not on its exact-match allowlist, and `dmTokenEnv` normalises to `dmtokenenv`, which is not on it. The names now match the `rc:` block's. 3. **`arms.directorArchive` is 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. 4. **`director.UnwiredConvener`** stands in for the roundtable convene: the ordinary spawn path is keyed on a Plane ticket (`spawn.Spec.TicketID`, `compiler.Spec.Ticket`) and **a meeting has none**, and no meeting-scoped context source exists. A complete `meeting.completed` therefore fails loudly instead of reporting a roundtable that never sat. 5. **The Director's DM decision marker is answered, not ingested durably**: the `DecisionSink` Plane writer is the documented LD2.3 follow-up, so the arm says what it parsed and states that the durable ingest is unwired. (R-7's escalation question is implemented in full.) 6. **No ninth context section** and no `compiler/roles.go` change: the persona joins `role-lore` through `persona.PersonaSource`, so `CanonicalSections` is untouched. 7. **The `rcbridge` chat projection of `meeting.completed` is not added**: `project.go` renders an unrecognised kind generically by contract ("anything else is rendered generically under its own name"), and nothing projects the meeting event in v1. ## Test plan → the ticket's three Verify items `go vet ./...` and `go test -race ./...` are green module-wide; **not one test opens a socket** (the Plane and RocketChat transports are stub `RoundTripper`s). **Verify 1 — "a Director hand-off request routes to the dispatcher, never executes itself"** - `director.TestHandOffEmitsExactlyOneItemAndNothingElse` (AC-1): a hand-off produces **exactly one** `create_item` call and the fixture's whole call log contains **nothing else** — the fixture has `spawn`/`claim`/`merge`/`delete` methods *so* the zero is an observation, not an argument. - `director.TestDirectorExposesNoForbiddenSurface` + `TestPlaneSinkIsExactlyTwoMethods` + `TestStopReleaserIsTheOnlyClaimShapedSurface`: the structural half, over the package's own syntax tree. - `cmd/dispatcher.TestDirectionDMMintsOneInboxItemAndAcknowledgesIt` (OQ-6) asserts one 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 refusal shapes (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/v1` comment, 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 one `fallback.notice`, every message still delivered, and **zero** RocketChat calls while degraded. - `comms.TestFailedPrimaryAttemptDegradesAndDelivers`, `TestRecoveryRestoresThePrimaryAndResetsTheNotice` (one notice per episode), `TestUnwiredSignalClientFailsLoudly`. **AC-4/5/6/7 (the rest of the brief's §5 map)** - AC-4 `persona`: canonical load + registry cross-check; account-not-derived refused with 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-lore` section through a real `compiler.Compile` with re-derived pins (a changed tone re-derives a different digest). - AC-5 `director` memory: an unresolvable `rc-dm:` ref excludes the record and faults at file + line (asserted at line 9 — the `ref`); a quarantined record is composed into 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-state` record carries no warrants and is pinned nowhere. - AC-6 `director` meeting: a missing transcript opens exactly ONE decision-queue item and convenes nothing; a complete event convenes exactly once; a malformed event writes nothing. - AC-7 `director` counters: `veto_rate = N/(N+M)`, refusals separate, overturn rate, band verdicts (unmeasured / too-low / in-band / too-high) and the telemetry map. - Plus the arm itself: four intents classified, own messages ignored, dedupe by message id, R-7 escalation question, and a refused chat delivery not stopping the Plane write. ## What remains for the landing worker / operator - **Not merged, and not to be merged by CI convention**: dangerous-adjacent review wanted on the `rcbridge` transport change (`im.create` is the first new endpoint since LD2.3) and on the identity-registry dormancy decision (OQ-7). - **Live halves are NOT verified by this worker** (instruction: no live mutations). Each needs the operator: (a) launch the Director arm with the `ld-el-director` account provisioned; (b) `director.signalFallback` stays false until a Signal egress reachable from the cluster exists; (c) a live P1 in the RC DM before the ladder flips. - **Deployment:** the `director:` block is already in `config/example.go`; the prod ConfigMap change is a separate PR in `prod` (arms OFF — inert keys only), and the `ld-el-director` OpenBao path + ExternalSecret is an operator-reviewed secrets change that this PR deliberately does not contain. - **Follow-ups named in the code and the README:** a reachable Signal egress, a meeting-scoped context source (so a meeting can be convened), the warrant archive reader, and `rcbridge.DecisionSink`'s Plane writer.
Adds the control-plane event vocabulary LD4.3 needs: EventKind with the
meeting.completed trigger (LD4.2 owns the producer) and the memory.candidate
record a routine promotes into a reviewed PR. MeetingCompleted.Validate
refuses a chat trigger with no warrant and a pipeline trigger that claims one;
Gaps() names the artifacts a half-recorded meeting is missing so the arm opens
ONE decision-queue item instead of convening on it.
ChatClient grows exactly one method: EnsureDM(username) -> roomID, over
POST /api/v1/im.create. It is the transport half of the Director's RC-DM
primary path (contract §7): a DM room is addressed by roomId like any channel,
so Post/History/Members are reused verbatim and there is no second transport.

The four-method surface assertion in credential_test.go moves to five
deliberately (that test is the reason a new method is a reviewable act), and
FixtureChatClient models the DM room so 'the DM is opened once' is provable
from the call log. No new exported struct, so the struct-walk guard is
unchanged. Tests keep using a stub RoundTripper: no test opens a socket.
Adds the el-director identity entries the persona cross-checks against:
plane, rocketchat and herdr. NO forgejo entry and NOT in activeRoles, both
deliberately — the completeness rule requires an ACTIVE role to bind every
surface with a tokenRef, and OQ-7 gives the fleet's voice no Forgejo token.
v1 runs the Director as a dispatcher arm (OQ-3), not a spawned sandbox, so
the dormant state is honest rather than a deferral.

This is the first entry set in the example that exercises 'a dormant role's
entries may be partial'; the pinned counts in the identity tests move 6->7
roles, 28->31 entries, 7->8 chat accounts, and the test now asserts the
OQ-7 property directly (no forgejo binding).
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.
HOMELAB-1687: docs(role-lore): seed the El Director corpora and document the slice's limits
Some checks failed
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) Failing after 53s
0/0 projects applied successfully.
22b5a93253
The memory corpus ships EMPTY and says why: a record is a claim about a human
backed by a citation, so seeding one would mean inventing the preference and
the warrant — the failure the store exists to prevent. Its README carries the
record schema, the maturity/warrant rules and the O6-analog TODO (the first
record lands from the first ratified DM exchange). The lore corpus is the same
shape, with the pin reminder.

director/seed_test.go pins the shipped corpus mechanically: zero faults, ZERO
records, no .yaml files, and an explicit empty section when composed. It also
checks the lore directory exists as real files, because compiler.FileSource
refuses a non-regular entry (a ConfigMap mount is a symlink farm until it is
materialised).

The dispatcher README gets the package map entries and an honest LD4.3 status:
the ticket's three Verify items and where each stands, plus the two gaps this
slice does not paper over — the Signal egress is unreachable from the cluster
(pi-signal-bridge is a systemd USER unit on the mgmt VM), and a meeting has no
ticket so the roundtable cannot be convened yet. Both refuse loudly and name
their follow-up instead of reporting an effect that did not happen.
HOMELAB-1687: fix(director): commit the unwired convener (which buildDirector wires) and ignore the dispatcher binary
All checks were successful
CI Review / helm-validate (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 22s
CI Review / dispatcher-test (pull_request) Successful in 2m3s
b185a9a99d
Two things the previous commits missed, found by checking the branch out
clean rather than trusting the working tree:

1. director/meeting.go's UnwiredConvener was staged but not committed by the
   previous pathspec-scoped commits, while cmd/dispatcher/app.go (committed)
   calls director.NewUnwiredConvener(). The pushed branch therefore did not
   compile. This commits it.
2. The executable a local build writes at lab-director/dispatcher/dispatcher
   is build output, never an artifact; .gitignore now names that one path so
   it cannot be staged by accident.

The convener refuses with the structural reason: the ordinary spawn path is
keyed on a Plane ticket (spawn.Spec.TicketID, compiler.Spec.Ticket) and a
meeting has none, so a complete meeting.completed event fails loudly instead of
reporting a roundtable that never sat.
HOMELAB-1687: fix(comms): refuse a ladder with no primary transport before it panics
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 1m59s
e4ea1746da
A nil Client would panic inside the P1 delivery path the ladder exists to
serve, and a panic in the escalation rail is the worst possible failure mode:
the page that never arrives because the process died trying to send it. The
ladder now refuses up front, like it already does for a missing Signal leg, and
TestLadderWithoutAPrimaryRefuses pins that nothing is delivered on either leg.
Author
Owner

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 and go test -race ./... green module-wide in the PR worktree at e4ea174 (17/17 packages ok, exit 0). Forgejo CI run #1662 on e4ea174 green (dispatcher-test, alert-receiver-test, receiver-test, helm-validate). Merge-base with epic/LD4 = cd6db02 = the base → clean merge.

Acceptance criteria

  • AC-1 ✓ TestHandOffEmitsExactlyOneItemAndNothingElse reads the fixture's whole call log (Methods() == [create_item]) — and FixtureAgent deliberately carries Spawn/Claim/Merge/Delete, so the zero is an observation, not an argument. Structural half holds: PlaneSink is exactly {Comment, CreateItem} (reflection-pinned), boundary_test.go walks the package's own syntax tree for forbidden verbs exempting only Fixture* receivers and fails if it finds no fixture surfaces to exempt (the guard cannot silently pass), ItemDraft carries no state/assignee/label, no file in director/ imports spawn/claims/MCP, and config.DefaultTemplates is untouched (class→template A–E only). v1 runs the Director as an arm, so there is no Director sandbox template to reach.
  • AC-2 ✓ (unit) empty named_gap → ReasonVetoUnnamedGap with the teaching sentence; missing/malformed warrant → ReasonVetoUnwarranted from Validate, unresolvable warrant → the same class from File's Resolver; self-convened artifact → ReasonVetoSelfConvened; stopping class with no releaser refused before writing. Each case asserts len(agent.Calls()) == 0 and that refusals move Refusals while Vetoes stays 0.
  • AC-3 ✓ attention.push + 24h nudge.r04 → zero Signal calls; escalation.p1 → exactly one dual-post; failing probe → exactly one fallback.notice per episode with every message still delivered and zero RocketChat calls while degraded (no retry storm). The refusal is fail-loud, not silent: UnwiredSignalClient wraps ErrSignalUnwired naming pi-signal-bridge, and director.signalFallback: true is refused at config load with FaultUnwiredArm (fault-tested). The ladder is not flipped, which is correct per the R-4 dual-post gate.
  • AC-4 ✓ strict loader (unknown key/line-numbered faults), registry cross-check on account and Plane/RocketChat bindings, and NEVER-set deviation refused for add / remove / re-order; canonical persona renders into role-lore through a real compiler.Compile with the pin re-derived.
  • AC-5 ✓ unresolvable rc-dm: ref excludes the record and the fault names file + line (asserted at the ref line); quarantined/conversation-state records composed nowhere; contradicts escalates; established needs two independent sources.
  • AC-6 ✓ (unit) + accepted deviation gap → exactly one decision-queue item, no convening; complete → exactly one convene. UnwiredConvener fails 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.
  • AC-7 ✓ veto_rate = N/(N+M) with refusals separate, overturn rate, and the 0.05–0.35 quarter band resolving unmeasured below the proposal floor (baselines #8 honoured).

Claim honesty: deviations 1–4 all match the diff. Deviation 1 is verifiable in-tree — .forgejo/workflows/ci.yaml mounts only $PWD/lab-director/dispatcher into the golang:1.24 container, so an out-of-module persona genuinely could not be validated or pinned. OQ-7 dormancy is registry-test-asserted (no forgejo entry; not in activeRoles).

House rules: the new tests open no socket (RoundTripper stubs + fixtures); the only net.Listen/httptest uses 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.vetoes is constructed in buildDirector (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; VetoService is 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.Item is documented as "a key such as HOMELAB-1686" and flows to PlaneSink.Comment → POST /issues/{key}/comments/ and (R-6) to claims.APIWriter.Transition → GET/PATCH /issues/{key}/. This codebase addresses Plane issues by UUID; claims derives the human key from sequence_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 documenting Item as a UUID. I did not probe the live Plane API (no mutations allowed), so this is a flag, not a proven failure.

Suggestions

  • gofmt -l flags persona/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.Release interpolates the veto reason (which embeds the DM-supplied named_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 failed escalation.p1 attention leg still has Delivery.Route == rc-dm. The ladder already reports the distinction; the log should use it.
  • PR-body nit: "el-director binds 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/LD4 is at cd6db02 in 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 from live, so merging to epic/LD4 publishes nothing. prod #871 stays operator-held; nothing downstream to watch from this merge.

Result: no blocker findings open. Merging.

## 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 and `go test -race ./...` green module-wide in the PR worktree at e4ea174 (17/17 packages `ok`, exit 0). Forgejo CI run **#1662 on e4ea174** green (dispatcher-test, alert-receiver-test, receiver-test, helm-validate). Merge-base with `epic/LD4` = `cd6db02` = the base → clean merge. ### Acceptance criteria - **AC-1 ✓** `TestHandOffEmitsExactlyOneItemAndNothingElse` reads the fixture's *whole* call log (`Methods() == [create_item]`) — and `FixtureAgent` deliberately carries `Spawn/Claim/Merge/Delete`, so the zero is an observation, not an argument. Structural half holds: `PlaneSink` is exactly `{Comment, CreateItem}` (reflection-pinned), `boundary_test.go` walks the package's own syntax tree for forbidden verbs exempting only `Fixture*` receivers *and* fails if it finds no fixture surfaces to exempt (the guard cannot silently pass), `ItemDraft` carries no state/assignee/label, no file in `director/` imports `spawn`/`claims`/MCP, and `config.DefaultTemplates` is untouched (class→template A–E only). v1 runs the Director as an arm, so there is no Director sandbox template to reach. - **AC-2 ✓ (unit)** empty `named_gap` → `ReasonVetoUnnamedGap` with the teaching sentence; missing/malformed warrant → `ReasonVetoUnwarranted` from `Validate`, unresolvable warrant → the same class from `File`'s `Resolver`; self-convened artifact → `ReasonVetoSelfConvened`; stopping class with no releaser refused **before** writing. Each case asserts `len(agent.Calls()) == 0` and that refusals move `Refusals` while `Vetoes` stays 0. - **AC-3 ✓** `attention.push` + 24h `nudge.r04` → zero Signal calls; `escalation.p1` → exactly one dual-post; failing probe → exactly **one** `fallback.notice` per episode with every message still delivered and **zero** RocketChat calls while degraded (no retry storm). The refusal is fail-loud, not silent: `UnwiredSignalClient` wraps `ErrSignalUnwired` naming `pi-signal-bridge`, and `director.signalFallback: true` is refused at config load with `FaultUnwiredArm` (fault-tested). **The ladder is not flipped**, which is correct per the R-4 dual-post gate. - **AC-4 ✓** strict loader (unknown key/line-numbered faults), registry cross-check on account *and* Plane/RocketChat bindings, and NEVER-set deviation refused for add / remove / re-order; canonical persona renders into `role-lore` through a real `compiler.Compile` with the pin re-derived. - **AC-5 ✓** unresolvable `rc-dm:` ref excludes the record and the fault names file + line (asserted at the `ref` line); quarantined/`conversation-state` records composed nowhere; `contradicts` escalates; `established` needs two *independent* sources. - **AC-6 ✓ (unit) + accepted deviation** gap → exactly one decision-queue item, no convening; complete → exactly one convene. `UnwiredConvener` fails 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. - **AC-7 ✓** `veto_rate = N/(N+M)` with refusals separate, overturn rate, and the `0.05–0.35` quarter band resolving `unmeasured` below the proposal floor (baselines #8 honoured). **Claim honesty:** deviations 1–4 all match the diff. Deviation 1 is verifiable in-tree — `.forgejo/workflows/ci.yaml` mounts only `$PWD/lab-director/dispatcher` into the `golang:1.24` container, so an out-of-module persona genuinely could not be validated or pinned. OQ-7 dormancy is registry-test-asserted (no `forgejo` entry; not in `activeRoles`). **House rules:** the new tests open no socket (RoundTripper stubs + fixtures); the only `net.Listen`/`httptest` uses 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.vetoes` is constructed in `buildDirector` (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; `VetoService` is 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.Item` is documented as "a key such as `HOMELAB-1686`" and flows to `PlaneSink.Comment` → `POST /issues/{key}/comments/` and (R-6) to `claims.APIWriter.Transition` → `GET/PATCH /issues/{key}/`. This codebase addresses Plane issues by **UUID**; `claims` derives the human key *from* `sequence_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 documenting `Item` as a UUID. I did not probe the live Plane API (no mutations allowed), so this is a flag, not a proven failure. ### Suggestions - `gofmt -l` flags **`persona/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.Release` interpolates the veto reason (which embeds the DM-supplied `named_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 failed `escalation.p1` attention leg still has `Delivery.Route == rc-dm`. The ladder already reports the distinction; the log should use it. - PR-body nit: "`el-director` binds **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/LD4` is at `cd6db02` in 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 from `live`, so merging to `epic/LD4` publishes nothing. `prod` #871 stays operator-held; nothing downstream to watch from this merge. **Result: no blocker findings open. Merging.**
aaron merged commit a9c72878b3 into epic/LD4 2026-10-02 01:07:09 +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!569
No description provided.