HOMELAB-1729: LD4.9 plan-freshness — boundary trigger, deterministic diff, receipts #575
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!575
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1729-planfresh"
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.9 · plan-freshness re-derivation per
.rpiv/artifacts/epic-ld4/design-LD4.9-plan-freshness.md, under the orchestrator rulings:<h2>Scope</h2>checklist; unmappable prose → advisory only;Built
watch.Issue.Parentdecode (flex: UUID string or expanded object)watch/client.go,watch/planeapi.goplanfresh/scope.goplanfresh/diff.goplanfresh/pass.go,planfresh/comment.goticket.doneboundary arm (own watermark, read-only Plane client)cmd/dispatcher/boundary.goarms.boundary,boundary:block,templates.plan-driftconfig/config.go,config/load.go,config/example.gopausedroutines/registry.yamlThe asymmetry is the cost brake. D1 (planned-but-unticketed) and D2 (unplanned child) are identifier mismatches: they raise the drift verdict and are the only classes that create work. D3 (stale tick) and D4 (uncited scope item) are advisory lines in the same epic comment — prose can never spend an LLM. A pass with only advisories is a visible
success-noop.Receipts (R-25 design): a no-change pass returns a visible
success-noopcarrying the epic-comment ref, and the comment opens withno change. A pass that could not look returns an empty receipt →missed, neversuccess-noop(P-ROUT-004); the read failure is folded into the receipt rather than returned as an error precisely so the runner recordsmissedand notfailed. A pass whose disposition failed returns an error (failed) and keeps the comment ref as evidence.R-25 ships
pausedwithpausedReason: dep:routine-executor. Honest reason: running an event-scheduled routine needs a trigger threaded throughroutines.Runner.Run, a liveExecutionPlaneand anExecutor; none are wired in this build. The arm still proves the trigger exists — the default sink resolves the registry, names the claimant, logs its paused reason, and says in as many words that NO RUN WAS STARTED. An active claim with no executor is a WARN, never a silent success. The mechanism itself (planfresh.Pass) is complete and fixture-tested; only the runner seam is missing.Stop condition — answered, not engineered around
The per-epic child scan does not blow past a project-scan-per-poll design, so no per-epic watermark is needed: the arm scans the project, once per interval, shared by every epic.
pages_steady = 1 + ceil(overlap / span-per-page)(proved byTestBoundaryArmSteadyStateReadsTheOverlapOnly: 3 pages cold, 2 for one new boundary), so on the brief's measured project (1497 issues / 15 pages) it is 15 requests cold, 1–2 in steady state, no model call. The only LLM spend in the feature is the architect lane, downstream of a drift finding.Shared-file hunks (siblings: please skim)
cmd/dispatcher/app.go— the boundary arm wiring +newBoundaryArm+loadRoutinesRegistry. No other arm touched.config/config.go/load.go/example.go— onlyArms.Boundary, theBoundaryblock,TemplateKindPlanDrift, and their validation.watch/client.go+watch/planeapi.go— one additive field each.routines/registry.yaml— R-25 only.routines/registry_test.go); an entry outside R-01…R-22 must name its owning ticket (routines/parity_test.go);routines -check's expected total is derived from the registry (cmd/routines/main_test.go).Not touched:
direction/,intake/,notifier/,persona/,director/,comms/, or any otherroutines/internals.Verification
go vet ./...clean.go test -race -count=1 -p 1 ./...green (23/23 packages).-p 1only because the VM's disk is at 100% — an unconstrained parallel run fails to link (no space left on device), which is environmental, not a test failure.go run ./cmd/routines -check -registry ./routines/registry.yaml→ exit 0,23 routine(s), 3 active, R-25 paused ondep:routine-executor.planfreshand the boundary arm are fixture-driven (watch.FixtureClient); the onlynet/httpin a test file is the pre-existing in-processroundTripFuncinwatch/planeapi_test.go.planfresh92.9% of statements.Deferred (named, not silently dropped)
routines.Runnerwith aTrigger, a liveExecutionPlane, anExecutorbound toplanfresh.Pass, and theplan-driftbranch inlaneForIssue. Until then R-25 stays paused andarms.boundarystays off.watch.FileWatermarkStore), which is what makescoldStartPolicy: failviable; both observers share this follow-up.emitsWorkon R-25 must be set when the executor lands, with an edge to whichever routine consumesplan-driftitems — inventing an edge to R-12 (a calendar sweep) today would be a fiction.The LD4.9 mechanism as a pure, fixture-testable package: scope.go the plan of record: the epic description's <h2>Scope</h2> checklist, parsed and normalised (entities, tags, three citation shapes; a citation key must be >=3 chars so `R-25` cannot become a manufactured finding) diff.go the 4-class diff. D1 (planned, unticketed) and D2 (unplanned child) are identifier mismatches -> drift. D3 (stale tick) and D4 (uncited scope item) are advisory lines only. That asymmetry is the cost brake: prose can never dispatch the architect lane. pass.go one pass over one epic: read, diff, record on the epic, and on drift create exactly ONE queued dispatch issue (Todo + label plan-drift + `ld:plan-drift` marker and JSON payload). R-25 never drafts a ticket body. Receipts follow the R-25 design: a no-change pass returns a VISIBLE success-noop carrying the epic comment ref (the comment itself opens with "no change"); a pass that could not LOOK returns an EMPTY receipt which is `missed`, never success-noop. A failed disposition returns an error so the runner records `failed`, keeping the epic comment ref as evidence. Child enumeration is modelled as `Children(ctx, epicID)` rather than a query because the self-hosted proxy ignores `?parent=` (see the watch commit). 92.9% statement coverage; every test is fixture-driven and none opens a socket.The trigger source, the disposition lane and the registry record: * `arms.boundary` (OFF by default) plus a `boundary:` block — interval, coldStartPolicy, pageSize/maxPages, overlap, emittedRetention. The cold-start policy has NO default and is REQUIRED when the arm is on: a scan that finds no watermark must be told whether the downtime was absorbed or lost, and the loader will not choose that for the operator (the watch's rule, restated for the second observer). * `templates.plan-drift` — the LD4.9 item kind, alongside LD4.1's two. It answers "which lane serves this item kind", not "how risky is this ticket", so it is a template key and not a class letter. * R-25 registers in the committed registry as `onEvent: ticket.done` + `scope: epic`, `cadencePeriod: week`, outputContract planeComment, status PAUSED with `pausedReason: dep:routine-executor` — the honest reason: the event-trigger seam through routines.Runner, a live ExecutionPlane and an Executor are not wired. `emitsWork` is deliberately unset (the entry is paused, so it creates nothing); when the executor lands, the drift dispatch issue must be declared there as an edge to whichever routine consumes `plan-drift` items — inventing an edge to R-12 (a calendar sweep) would be a fiction. * app.go wires the arm as a NON-FATAL runner and builds its OWN in-process watermark store (never the watch's). It reads the routines registry from ROUTINES_REGISTRY_PATH best-effort: a missing mount is a WARN and the sink logs `registry_loaded=false`, so "no mount" is never confused with "no routine wants this event". Three sibling-facing test adjustments, all growth-tolerant so the other 4.x registrants (R-24 next) do not have to touch them again: the registry census count is now a floor plus a named R-25 shape assertion; the corpus-parity rule now requires an entry outside R-01..R-22 to name its owning ticket; and `routines -check`'s expected total is derived from the registry instead of hardcoded. `routines -check` is green (23 routines, 3 active, R-25 paused on dep:routine-executor).Landing review — verdict: pass (merge)
Reviewed head
968fe7334852f18272bb84358bd5b9c525a4240b(baseepic/LD4@1519dd2, unmoved). Fresh context; primary sources only: the diff (23 files), the ticket's Verify lines, the design brief (design-LD4.9-plan-freshness.md), and the HOMELAB-1729 orchestrator adjudication.CI
forgejo_ci_status Homelab/infra-core #575→ run #1689 @968fe73348: pass (alert-receiver-test, dispatcher-test 2m25s, helm-validate, receiver-test). The job runsgo vet ./... && go test -race ./...in a freshgolang:1.24container with no-p 1— so the PR body's-p 1/ disk-at-100% note is confirmed environmental: CI is not affected (it ran the unconstrained parallel race suite green). Race is enforced by CI, not only locally.Verify lines — real tests, behaviour not implementation
TestPassCatchesPlantedGapAtABoundary(withTestDiffPlantedGapat the pure layer): the epic comment goes to the epic issue, body assertsdrift/D1/HOMELAB-1799/epic revision; exactly one dispatch issue, stateTodo, labelslab-director,plan-drift,ld:plan-driftmarker whose<pre>payload round-trips (ParseDispatchDescription) and names the D1 finding + the comment ref; plus an explicit negative assertion that the pass wrote nothing else (1 comment, 1 issue — R-25 drafts no ticket body).TestPassNoChangeIsVisible: the comment opens with<p>no change —(structural prefix assert, not a substring), receiptsuccess-noop+Noop, refs = exactly the comment, 0 dispatch issues. The anti-confusion half is real too:TestPassReadFailureIsMissedproves a pass that could not look returns an empty receipt →missed, neversuccess-noop— checked againstroutines.classify(err→failed,Empty()→missed,Noop→success-noop), which matches the three failure shapes documented inpass.go.Other review dimensions
TestBoundaryArmSteadyStateReadsTheOverlapOnlycounts actual calls:planes.Calls("Issues")delta == 2 in steady state vs 3 pages cold, plus asecond.Pages >= first.Pagesfail-fast. The arithmetic andpages_steady = 1 + ceil(overlap/span-per-page)hold; 15 pages on the measured project follows from pageSize 100 / 1497 issues.TestPassAdvisoryOnlyPassIsNoopButRecorded(noop, 0 dispatches, D3+D4 lines in the same comment) andTestDiffAdvisoryOnlyIsNotDrift.TestDiffTextMatchedProseIsNotD4correctly keeps text-matching (which only suppresses D4) from manufacturing a citation.net/http/httptest/net.Dialin any new test;planfreshand the arm are fixture-driven (fixtureReader/fixtureSink,watch.FixtureClient). Reproduced locally:go test -race -run 'Boundary|DoneTriggers|ShippedRegistry' ./cmd/dispatcher/andgo test ./planfresh/green;go veton all five touched packages clean; coverage 92.9% reproduced exactly.watch.PlaneClient(5 read methods, no writes); the sole write seam isplanfresh.Sink's two methods — epic comment + dispatch issue. Structurally true, not just claimed.paused(dep:routine-executor),emitsWorkunset, noscriptRefpin (the orchestrator comment of 2026-10-02 records "R-25 registers as paused until the executor lands"); the mechanism lives inplanfresh/rather thanroutines/planfresh.go;laneForIssue/context_plane.gountouched (only theplan-drifttemplate key added); the three growth-tolerant test edits are present as described.go run ./cmd/routines -check→ exit 0,23 routine(s), 3 active, R-25 paused — reproduced.parentis aplaneRef(pre-existing string-or-object unmarshaler), one field inclient.go+ one mapping inplaneapi.go; the new test covers string, expanded-object,nulland absent shapes — the absent case matters because a phantom parent would emit a boundary for a ticket with no epic.Non-blocking concerns (may merge; no blocker findings)
intake (4.1),routines registry (4.4), fully uncited items) — yields one D2 per child → drift → one architect dispatch. The "prose can never spend an LLM" brake holds for D3/D4 but not for D2 against a plan that cites by section number. Design-ratified (§3 table, D2 = high) and dormant today (arm off, executor unwired), so not a blocker — but the first real run should be watched.parentas an epic scope (no work-item-type check). This matches the ratified ruling verbatim ("terminal state group and has a parent"), and a non-epic parent degrades tono plan of record→ visible no-change; noted only because the code comments say "inside an epic".DefaultBoundaryWatermarkPathis declared and intentionally unused (documented);TemplateKindOf's doc comment still says "LD4.1 item-kind TEMPLATE keys" after growing to three keys — one stale word.coldStartPolicy: adopt-baselinebecause the store is in-process, so a dispatcher restart silently absorbs Done boundaries. Documented as the durable-store follow-up; fine while the arm ships off.Incidental tooling finding (not this PR)
forgejo_comment_on_prwithrepo_ref: aaron/infra-corereported success and wrote nothing: the owner path 301-redirects toHomelab/infra-core, and nodefetchfollows a 301 by rewriting POST→GET, dropping the body. The canonicalHomelab/infra-coreref works (this comment). Worth a guard in the extension: never treat a 3xx as a successful write, or normalize the owner.No blocker findings open. Merging.