HOMELAB-1690: feat(pi-friction): the L7 friction capture surface (ld.friction/v1) #61
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(§1.2); orchestrator adjudication on the ticket supersedes.What
A new extension unit
pi-friction/, the capture surface for the friction log. Three surfaces, ONE record shape:pi.on("tool_execution_end")on failure shapes. Reads the tool NAME and the error CLASS and nothing else.frictiontool, with a prompt guideline to file before working around a wrong instruction./friction <surface>:<ref> <class> "expectation" | "reality" [blocks] [--ticket HOME-1], plus a JSON form through the same validator.Transport (LD4.1's posture, verbatim): spool → ledger → Plane.
~/.pi/agent/friction/<sessionId>.jsonlis a transport, not the record (the session JSONL is already durable);fleet.ledger.friction.lineis the durable citation home, appended atsession_shutdown(mgmt VM) or by the sandbox teardown drain; the friction ticket belongs to R-28, never to this unit.Verify lines mechanized here
/frictionare both validld.friction/v1records in the spool, flushed on the line subject (tests/extension.test.ts). The ticket half is the infra-core PR.where.refthat renders a call), each citingschema:ld.friction/v1#no-arguments.The no-arguments law (schema law, 4 enforcement points)
scanForArgumentsrefuses ~25 forbidden key families at any depth;where.refthat renders a call;looksLikeArgumentscatches a payload in another surface's ref;costs(blocksis what files a ticket on sight — P-L7-003, and the mechanism cannot know whether the actor proceeded).The test plants a live-looking secret in the event's
args/result/contentand asserts it appears nowhere in the spool bytes (the design scout's R2 mitigation, asserted rather than promised).Bounds
90d age (matching
FLEET_LEDGER's MaxAge, so the local and durable copy age out together) + a byte ceiling that drops the oldest first; atomic rewrite; malformed lines are reported, never dropped.JetStreamPublisheris a stub that refuses (an extension that cannot reach the bus must not look like one that did);NullPublisheris the log-only default.Cross-implementation check
One
ld.friction/v1record is pinned byte-for-byte here and in the Go decoder's test (infra-corePR). It caught a real divergence on the first run: Go omittedactor.sandboxEnvfor a human actor while this side stampedunbound. Both sides now stampunboundfor any actor with no sandbox.Verification
Deviations / notes
package-lock.jsonis committed (the sibling units commit theirs);node_modules/is gitignored.pi-otel-telemetrycoupling (the trace id is read fromTRACEPARENTwhen present; that unit is HOMELAB-1850's).ext/friction+roles.jsoninclude: ["friction"]) is NOT in this PR: it belongs with the sandbox renderer, and the mechanism does not depend on it (spool-first is what works today).The L7 loop's capture surface, as a shipped extension unit (contract §6; design brief §1.2). Three surfaces, ONE record shape, one spool, one ledger: - AUTOMATIC (zero LLM): pi.on("tool_execution_end") on failure shapes. It reads the tool NAME and the error CLASS and nothing else: expectation/reality are the authored-once structural pair per class, severity is always `costs` (the mechanism cannot know whether the actor proceeded, and `blocks` is what files a ticket on sight). - AGENT JUDGMENT: the model-callable `friction` tool, with prompt guidelines that say to file BEFORE working around a wrong instruction. - HUMAN JUDGMENT: `/friction <surface>:<ref> <class> "expectation" | "reality" [blocks] [--ticket HOME-1]`, plus a JSON form validated by the same validator. THE NO-ARGUMENTS LAW, enforced in code (schema law, not a prompt rule): ~25 forbidden key families are refused at any depth (scanForArguments), a where.ref that renders a call instead of naming a tool is refused, and the same law is enforced independently on the Go side (dispatcher/friction/line.go), so a violation must get past two implementations. - spool.ts: append-only JSONL, O_APPEND, one write per line, bounded at 90d (matching the FLEET_LEDGER stream's MaxAge) + a byte ceiling that drops the oldest first; atomic rewrite; malformed lines are REPORTED, never dropped. - ledger.ts: the publisher seam behind the existing shape (one method, one subject, one payload) — NullPublisher is the log-only DEFAULT (LD4.1 posture) and JetStreamPublisher is a stub that REFUSES, because an extension that cannot reach the bus must not look like one that did. Flush at session_shutdown (the mgmt-VM path; in a sandbox the teardown arm drains the same file). The spool is never deleted: this extension does not own the record. - capture.ts reads the trace id from TRACEPARENT and the sandbox binding from SANDBOX_ROLE, and deliberately depends on nothing in pi-otel-telemetry (HOMELAB-1850's territory). 96 vitest tests over a fake pi (no sockets), including the R2 assertion: a planted secret in the event's args/result/content appears NOWHERE in the spool bytes. tsc --noEmit clean. node_modules/ and package-lock.json: the repo gitignores node_modules; the lock is committed by convention in the sibling packages, see the PR body.Landing review — homelab-ticket pipeline (fresh context, primary sources only)
Verdict: APPROVE — no blocker findings. Head reviewed:
76ace316d1986395387cfdb822c64f56255bb31f(branchplane/HOMELAB-1690-l7, basemain).CI: run #118 on
76ace316d1pass, 13/13 jobs incl. the newpi-frictionjob (1m0s). Independently re-ran the unit locally:tsc --noEmitclean,vitest run99/99 pass (5 files) — the "96 tests" line inpi-friction/README.mdis stale (it is 99, which is what the ticket and CI comment cite).Dimensions checked
scanForArgumentson forbidden key families at any depth (arrays walked,a[0].argumentsasserted); the bare tool-name grammarTOOL_NAME_REforsurface: tool;looksLikeArgumentsfor other surfaces; and the closed schema (ALLOWED_KEYS+unknown_field) so a non-declared key cannot be a convenient home for content. Table cases: 16 refusal rows + thescanForArgumentsarray case = 17, all green. The auto path carries the authored-once structural pair and never claimsblocks— the right call for a mechanism that cannot know whether the actor proceeded.ld.friction/v1wire line is byte-identical inpi-friction/tests/types.test.tsandinfra-core#592line_test.go(sameactor:{kind:human,id:operator,sandboxEnv:"unbound"}, same fingerprint09e84eddcf81d8d7, asserted on both sides). ThesandboxEnv: unbound-for-any-actor-without-a-sandbox rule is asserted independently on each side, so the divergence the builder reports really is closed.isFailureisevent.isError === trueand nothing else; a successful call writes nothing, a nameless tool writes nothing. Content is never read for value — onlyArray.isArray(result.content)+ length to split refusal from error. The R2 mitigation is asserted, not promised: the tests plant a live-looking secret inargs/result/content/detailsand grep every spool byte for the secret,Authorization,curl,cat,/etc/shadowand thetoolCallId./friction+ tool + spool bounds. The command grammar and the JSON form both route through the one validator (a JSON payload withargumentsis refused withno-argumentsin the message). Spool: 90d (SPOOL_MAX_AGE_MS== the ledger stream's MaxAge) + 8 MiB byte ceiling dropping oldest-first, atomic tmp+rename, malformed lines reported and preserved through a prune. Flush does not delete the spool.NullPublisherlog-only default;JetStreamPublisherrefuses loudly — honest posture for an unadmitted bus client.working-directory, Node 22,npm cifrom the committed lockfile, typecheck +vitest run). It genuinely runs this unit's tests, and it was added after run #117 showed the suite was ungated — the right fix, and the gap it closed was real.pi-friction/. Nopi-otel-telemetry/pi-swarm-suite/pi-skillsfile touched, no Plane writes, no compiler/sandbox-renderer change.Non-blocking findings (may merge; none open as a blocker)
normalizeKeystrips[_\-/\s](all JS whitespace, incl. U+00A0 and other Unicode spaces); Go stripped only_,-,/and the literal space. Reachable only with exotic Unicode whitespace in a non-toolwhere.ref(plain space+separator and all control chars are already refused by TS), and the failure mode is a loudfingerprint_mismatchrefusal at decode, not silent corruption — so not a blocker. Worth one follow-up: pin the strip set to ASCII in both, or add a fixture case with a non-ASCII space./friction(the human surface) writesactor.kind: "agent"with the session id, so the schema'shuman/id: operatorshape (design §1.1) is accepted but never produced by this capture surface. Deliberate and tested (extension.test.ts), and the design leaves the choice open — but R-28 will not be able to tell an operator's/frictionfrom an agent's without the surrounding context.docs/pi-friction.md+ a root README row are not added (repo "Adding an extension" step 2). The closest sibling precedent,nats-intercom, is also absent from both, so this is consistent with recent practice rather than a regression — a one-line follow-up.Reviewed sources: PR diff (all 17 files), PR body, HOMELAB-1690 adjudication comments,
.rpiv/artifacts/epic-ld4/design-LD4.6-l7.md§1.1–§1.2,infra-core#592diff (cross-impl fixture), CI run #118. Reviewing context ≠ implementing context; no implementer summary was used as the brief.