HOMELAB-1690: feat(friction): the L7 mechanism — ld.friction/v1, ld.proposal/v1 state machine, shadow bindings, R-28 triage sink #592
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!592
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1690-l7"
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?
HOMELAB-1690 · LD4.6 · contract §6 (L7). Brief:
.rpiv/artifacts/epic-ld4/design-LD4.6-l7.md; orchestrator adjudication on the ticket supersedes (R-28 for the triage routine; the friction schema carries the tool name + error class and never arguments — schema law; 4.6 owns the shadow-binding mechanism; the kill arithmetic counts applied corrections; retention = bounded spool + stream bounds).Companion PR: Homelab/pi-extensions#61 (
pi-friction, the capture surface).What
A new
lab-director/dispatcher/friction/package, the durable half of L7, plus the R-28 registry entry:line.gold.friction/v1— the append-only line, closed class/surface/severity sets, derivable fingerprint, subjectfleet.ledger.friction.<entrykind>. The NO-ARGUMENTS LAW is enforced here too (forbidden key families at any depth; awhere.refthat renders an invocation is refused).proposal.gold.proposal/v1— one record per transition, the five states as pure functions (LegalEdge/Validate/DeriveState/Append), plus the L7-K0/K1/K2 kill arithmetic.shadow.go(role, class) → variantPin + sample,SampleUnit(hash of the dispatch key, so the pin is re-derivable),Apply= sample 1.0,Revert/RevertAndProve= restore the recorded sha256 pin, proven against the bytes that pin names.triage.godirector.PlaneSinkseam: group by fingerprint → dedup before any write → one open ticket per fingerprint, within a ticket budget → one receipt row onfleet.ledger.friction.triage.routines/registry.yamlfriction-triage, registeredpaused(SHARED file, one entry only).Verify lines mechanized
TestAFrictionLineBecomesATicket: the fixture's three auto-captured tool-error lines (three sessions) + one blocking/frictionline → exactly twocreate_itemcalls, bodies citing the friction id, fingerprint, session and trace refs; the aggregating class files nothing.TestASecondOccurrenceFilesNothing: a re-run with the open fingerprints files zero items and folds into comments.TestProposalStateMachinedrives the fixture: every legal edge derives its state,proposed→accepted→appliedasserted withevidenceRefsnaming the merged PR, andTestApplyAndRevertRestoreTheRecordedPinByteForByteproves the revert restores the recorded pin byte-for-byte (including a refusal case where the bytes moved).KillInputsFromLedger+EvaluateKill:applied==0 && observerCost>0kills under L7-K2 whatever the ratio and withunitValueabsent (the LD4.4 §7.1 R6 fallback); L7-K1 is explicitly undecided rather than a pass when the denominator does not exist; a single window never decides;E4Decaycounts active routines only.Anti-spam (a receipt requirement, not a hope)
Only
severity: blocksor a fingerprint seen ≥3× files; at mostDefaultTicketBudget(3) new tickets per run; work-not-done is deferred and printed. 50 annoyances across 5 surfaces → ≤3 tickets (TestTheTicketBudgetHolds).Cross-implementation check
One
ld.friction/v1record is pinned byte-for-byte in this repo's test and in the capture surface's test. It caught a real divergence: Go omittedactor.sandboxEnvfor a human actor whilepi-frictionstampedunbound. Both sides now stampunboundfor any actor with no sandbox.Verification
Deviations from the brief (both deliberate, both named in the entry)
ticketBudgetPerRunis not a registry field. The registry loader isKnownFields(true)and has no such key; adding one is aroutines/schema change this ticket does not own. The bound lives infriction.DefaultTicketBudgetand is named in the R-28 description.effects.writesis not a field (theEffectsschema hasreadsonly), so the entry declaresreads: [jetstream:friction, jetstream:proposal, plane]and the entry's comment says Plane is the write.pausedfordep:routine-executor(the task brief's instruction, matching R-22/R-25): the mechanism ships here, the executor that schedules it does not — so noscriptRefpin is claimed.testdata/proposal-transitions.jsonrather than.yaml: the ledger row is JSON, so the fixture exercises the wire decoder (strict fields, closed schema) instead of a second serialization.Not in this PR (named, not forgotten)
teardown/spool drain androles.jsoninclude: ["friction"]— the deployment half; the mechanism deliberately does not depend on them (spool-first).P-L7-001…010do not land inroutines/policy.md: that isroutines/internals and belongs with the R-28 activation PR; duplicating the policy book would give the fleet two places for one rule.prodmanifests: R-28 rides the existing routine mechanism; the NATS subject filter for the two new subject families is an additive manifest change when the executor lands (and 4.10 consumes this ledger).For 4.10 to consume
The proposal ledger (schema + append path + derived-state read), the friction ledger (schema + subjects + retention), the
frictionRefsjoin, the shadow-binding mechanism (pin selection + a samplecontext-bindings.json+ the provable revert), and R-28's ledger for its sampled scan.6caa69e0ff71631d8b18Re-id + rebase (HOMELAB-1690, fix worker)
Rebased onto
epic/LD4@2607c6e(R-29 / PR #594 landed). One conflict, inroutines/registry.yaml's RESERVED IDS header — resolved to the merged totals (R-28 stays LD4.5b's on-call window, R-29 phase census, LD4.6 added) and then re-id'ed.Re-id: L7
friction-triageR-28 → R-30 everywhere in this branch (registry entry id + header mention, and the 12dispatcher/friction/**files that named it: docs, comments, the Plane-item description string intriage.go, test descriptions, testdata). R-28's reservation prose for 4.5b is untouched. No R-28 reference to LD4.6 remains in the tree.Checks on head
71631d8(lab-director/dispatcher):go test -race ./routines/... ./cmd/routines/...→ ok (routines 10.8s, cmd/routines 1.9s);go test -race ./friction/...→ okgo vet ./routines/... ./cmd/routines/...→ cleango run ./cmd/routines -check -registry ./routines/registry.yaml→26 routine(s), 5 active, 21 paused, 0 retired(22 corpus + R-24 + R-25 + R-29 + R-30; R-28 is not yet on epic/LD4, so 27 once #596 / 4.5b lands)Not merged — landing worker holds.
Landing review — VERDICT: approve (no blocker findings). Reviewed head
71631d8(post-rebase, post-re-id), fresh context, primary sources only: the diff, HOMELAB-1690's acceptance lines, the design brief.rpiv/artifacts/epic-ld4/design-LD4.6-l7.md, and the repo's own contracts (routines/registry.go,director/{handoff,plane}.go,sentinel/ledger.go,direction/ledger.go).Evidence I re-derived myself (not read from the PR body)
71631d8— all four jobs pass (dispatcher-test 3m39s, helm-validate, alert-receiver-test, receiver-test). Independently:go vet ./...clean andgo test -race -count=1 ./...green inlab-director/dispatcher(24 packages ok, no data race, friction 1.1s). No sockets:friction/*.goimports nonet/http/nats.go run ./cmd/routines -check -registry ./routines/registry.yaml→ 26 routines, 5 active, 21 paused, 0 retired. R-30 loads,status: paused,pausedReason: dep:routine-executor. Merged totals resolve cleanly now that the branch is rebased: 22 corpus + R-24 + R-25 + R-29 + R-30 = 26. The census test's floor is derived from the corpus fixture (len(corpusRows)+2), so R-30 required no test-count edit.origin/epic/LD4is exactly the header amendment + the new entry. R-28's reservation prose for LD4.5b is byte-identical; no R-28 reference to LD4.6 survives in the tree (22 remaining hits are all 4.5b's reservation). R-30 is free on the epic.Per-dimension findings
tool-error≥ MinOccurrences, 1×blocks);create_itemcount asserted = exactly 2, and the re-run over an open-fingerprint map asserts 0 new items + 2 fold comments. The 50-annoyances/5-surface case yields 3 filed + 2 deferred (printed, not dropped).Planis a pure function over injected state, so the bounds are assertable without Plane.DeriveState/Appendare pure; the test replays throughAppendand asserts agreement withDeriveState. Shape matchessentinel/direction(<schema>/v1const, closedEntryKind+Valid()+Subject(), payload-only, stamped inMarshalJSON, strictDisallowUnknownFieldsdecode).SelectPinis a hash ofticket|role|class|attempt(re-derivable, 0.25±0.05 share over 2000 keys); the sampletestdata/context-bindings.jsonloads validating. The revert is proven against the bytes the pin names, and refused (ReasonPinMismatch) when the bytes moved — the provable-revert line holds.where.refinvocations, shell pipelines) all refused withReasonArgumentsPresent, each citingschema:ld.friction/v1#no-arguments. Cross-implementation: I confirmed the pinned record + fingerprint09e84eddcf81d8d7is byte-identical inpi-extensionspi-friction/tests/types.test.ts:240, so the divergence-catching claim is real, not asserted.applied==0 && cost>0kills under L7-K2 withunitValuenil (and is not rescued by a huge denominator); L7-K1 returnsDecided=false— undecided, not a pass; one window never decides;E4Decayis active-only. Numerator countsapplied(a reverted-but-applied record still counts), plus friction tickets that reached Done.lab-director/dispatcher/friction/**(new) +routines/registry.yaml(entry + header). Noclaims/routinesinternals beyond the entry; no policy-book edit claimed.Non-blocking concerns (no fix required to merge; for the activation PR)
Bindings.RevertsetsSample = 0+VerdictAt, butBindings.Validaterefusessample <= 0(asserted byTestBindingsRefusals/sample_zero). So the in-memory revert proof is sound, yet a revertedcontext-bindings.jsonwould be refused byLoadBindings. Either allowsample == 0whenVerdictAt != nil, or document "delete the entry on revert" — worth pinning before the file is real.filter: q2_derivationistruein the entry,falsein brief §3.2.trueis the correct answer givenkind: script(the loader's q2-true ⇒ code implication), but it is absent from the deviations list.Triage.Runtakes the open-fingerprint map from the caller; the reader that resolves "open ticket for this fingerprint" lands with the executor. Fine whilepaused; name it in the activation ticket.Homelab/pi-extensions#61still carries 14R-28references inpi-friction/**(README,index.ts,ledger.ts, types, tests) — that companion needs the same re-id to R-30.epic/LD4(census: 5 active). It becomes true when #596 lands.Proceeding to merge on the landing lock (green CI on this head, verdict posted after it, no blocker findings).