HOMELAB-1690: feat(pi-friction): the L7 friction capture surface (ld.friction/v1) #61

Merged
aaron merged 3 commits from plane/HOMELAB-1690-l7 into main 2026-10-03 03:57:11 +00:00
Owner

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:

  • Automatic (zero LLM) — pi.on("tool_execution_end") on failure shapes. Reads the tool NAME and the error CLASS and nothing else.
  • Agent judgment — the model-callable friction tool, with a prompt guideline to file before working around a wrong instruction.
  • Human judgment — /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>.jsonl is a transport, not the record (the session JSONL is already durable); fleet.ledger.friction.line is the durable citation home, appended at session_shutdown (mgmt VM) or by the sandbox teardown drain; the friction ticket belongs to R-28, never to this unit.

Verify lines mechanized here

  1. A friction line becomes a ticket — this PR's half: a line auto-captured from a fixture failure shape and a line from /friction are both valid ld.friction/v1 records in the spool, flushed on the line subject (tests/extension.test.ts). The ticket half is the infra-core PR.
  2. The no-arguments law — 17 table cases refuse a line carrying arguments (top-level, nested, and a where.ref that renders a call), each citing schema:ld.friction/v1#no-arguments.

The no-arguments law (schema law, 4 enforcement points)

  1. scanForArguments refuses ~25 forbidden key families at any depth;
  2. the tool-name grammar refuses a where.ref that renders a call;
  3. looksLikeArguments catches a payload in another surface's ref;
  4. the automatic path writes no content: its pair is the authored-once structural pair per class, and severity is always costs (blocks is 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/content and 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. JetStreamPublisher is a stub that refuses (an extension that cannot reach the bus must not look like one that did); NullPublisher is the log-only default.

Cross-implementation check

One ld.friction/v1 record is pinned byte-for-byte here and in the Go decoder's test (infra-core PR). It caught a real divergence on the first run: Go omitted actor.sandboxEnv for a human actor while this side stamped unbound. Both sides now stamp unbound for any actor with no sandbox.

Verification

npm install && npm run typecheck && npm test
# tsc clean; 99 vitest tests over a FAKE pi — no socket, no Plane, no bus

Deviations / notes

  • package-lock.json is committed (the sibling units commit theirs); node_modules/ is gitignored.
  • No Plane writes, no compiler/sandbox-renderer changes, no pi-otel-telemetry coupling (the trace id is read from TRACEPARENT when present; that unit is HOMELAB-1850's).
  • Deployment wiring (context-package ext/friction + roles.json include: ["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).
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: - **Automatic (zero LLM)** — `pi.on("tool_execution_end")` on failure shapes. Reads the tool NAME and the error CLASS and nothing else. - **Agent judgment** — the model-callable `friction` tool, with a prompt guideline to file *before* working around a wrong instruction. - **Human judgment** — `/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>.jsonl` is a **transport, not the record** (the session JSONL is already durable); `fleet.ledger.friction.line` is the durable citation home, appended at `session_shutdown` (mgmt VM) or by the sandbox teardown drain; the friction **ticket** belongs to R-28, never to this unit. ## Verify lines mechanized here 1. **A friction line becomes a ticket** — this PR's half: a line auto-captured from a fixture failure shape *and* a line from `/friction` are both valid `ld.friction/v1` records in the spool, flushed on the line subject (`tests/extension.test.ts`). The ticket half is the infra-core PR. 3. **The no-arguments law** — 17 table cases refuse a line carrying arguments (top-level, nested, and a `where.ref` that renders a call), each citing `schema:ld.friction/v1#no-arguments`. ## The no-arguments law (schema law, 4 enforcement points) 1. `scanForArguments` refuses ~25 forbidden key families **at any depth**; 2. the tool-name grammar refuses a `where.ref` that renders a call; 3. `looksLikeArguments` catches a payload in another surface's ref; 4. the automatic path writes **no** content: its pair is the authored-once structural pair per class, and severity is always `costs` (`blocks` is 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`/`content` and 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**. `JetStreamPublisher` is a stub that refuses (an extension that cannot reach the bus must not look like one that did); `NullPublisher` is the log-only default. ## Cross-implementation check One `ld.friction/v1` record is pinned byte-for-byte here **and** in the Go decoder's test (`infra-core` PR). It caught a real divergence on the first run: Go omitted `actor.sandboxEnv` for a human actor while this side stamped `unbound`. Both sides now stamp `unbound` for any actor with no sandbox. ## Verification ``` npm install && npm run typecheck && npm test # tsc clean; 99 vitest tests over a FAKE pi — no socket, no Plane, no bus ``` ## Deviations / notes - `package-lock.json` is committed (the sibling units commit theirs); `node_modules/` is gitignored. - No Plane writes, no compiler/sandbox-renderer changes, no `pi-otel-telemetry` coupling (the trace id is read from `TRACEPARENT` when present; that unit is HOMELAB-1850's). - Deployment wiring (context-package `ext/friction` + `roles.json` `include: ["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.
HOMELAB-1690: docs(pi-friction): unit README + the cross-implementation wire fixture
Some checks failed
CI / pr-title (pull_request) Successful in 1s
CI / mcp-bridge (pull_request) Successful in 30s
CI / forgejo-ci (pull_request) Successful in 1m0s
CI / nats-intercom (pull_request) Successful in 30s
CI / skill-args (pull_request) Successful in 1m3s
CI / pi-otel-telemetry (pull_request) Successful in 1m6s
CI / todo (pull_request) Successful in 55s
CI / plane (pull_request) Successful in 1m17s
CI / forgejo (pull_request) Successful in 1m10s
CI / ask-user-question (pull_request) Successful in 56s
CI / refocus (pull_request) Successful in 1m3s
CI / pi-swarm-suite (pull_request) Has been cancelled
be191a3616
The README states the three surfaces, the law and its four enforcement points,
the spool->ledger->Plane transport, the configuration surface, and what is
deliberately NOT in this unit (no Plane writes, no compiler changes, no
pi-otel-telemetry coupling).

The cross-implementation fixture pins ONE ld.friction/v1 record byte-for-byte in
both repos (here and in the Go decoder's test), and it caught a real divergence:
the Go side omitted actor.sandboxEnv for a human actor while the capture surface
stamps . Both sides now stamp  for ANY actor with no sandbox,
so a reader never has to distinguish "no sandbox" from "not filled in", and the
test asserts the serialization equals the literal. 99 tests, tsc clean.
HOMELAB-1690: ci(pi-extensions): gate pi-friction (typecheck + vitest)
All checks were successful
CI / pr-title (pull_request) Successful in 1s
CI / forgejo-ci (pull_request) Successful in 41s
CI / pi-otel-telemetry (pull_request) Successful in 45s
CI / mcp-bridge (pull_request) Successful in 37s
CI / nats-intercom (pull_request) Successful in 43s
CI / ask-user-question (pull_request) Successful in 1m0s
CI / refocus (pull_request) Successful in 1m1s
CI / skill-args (pull_request) Successful in 1m5s
CI / pi-friction (pull_request) Successful in 1m0s
CI / todo (pull_request) Successful in 1m10s
CI / plane (pull_request) Successful in 1m18s
CI / forgejo (pull_request) Successful in 1m18s
CI / pi-swarm-suite (pull_request) Successful in 3m59s
76ace316d1
The workflow's own rule is a one-line job copy per package once it declares
typecheck/test scripts, and the first run of this PR showed pi-friction was NOT
in the matrix: the new unit's 99 tests would have merged ungated. Same shape as
the sibling jobs (Node 22, npm ci from the committed lockfile, typecheck, vitest);
the suite drives the whole extension through a fake pi, so it needs no socket, no
Plane and no NATS.
Author
Owner

Landing review — homelab-ticket pipeline (fresh context, primary sources only)

Verdict: APPROVE — no blocker findings. Head reviewed: 76ace316d1986395387cfdb822c64f56255bb31f (branch plane/HOMELAB-1690-l7, base main).

CI: run #118 on 76ace316d1 pass, 13/13 jobs incl. the new pi-friction job (1m0s). Independently re-ran the unit locally: tsc --noEmit clean, vitest run 99/99 pass (5 files) — the "96 tests" line in pi-friction/README.md is stale (it is 99, which is what the ticket and CI comment cite).

Dimensions checked

  1. No-arguments law (schema law). Enforced in TS at four points as claimed: scanForArguments on forbidden key families at any depth (arrays walked, a[0].arguments asserted); the bare tool-name grammar TOOL_NAME_RE for surface: tool; looksLikeArguments for 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 + the scanForArguments array case = 17, all green. The auto path carries the authored-once structural pair and never claims blocks — the right call for a mechanism that cannot know whether the actor proceeded.
  2. Cross-impl alignment — verified against both sides, not the summary. The pinned ld.friction/v1 wire line is byte-identical in pi-friction/tests/types.test.ts and infra-core#592 line_test.go (same actor:{kind:human,id:operator,sandboxEnv:"unbound"}, same fingerprint 09e84eddcf81d8d7, asserted on both sides). The sandboxEnv: unbound-for-any-actor-without-a-sandbox rule is asserted independently on each side, so the divergence the builder reports really is closed.
  3. Auto-capture is failure-shaped, not ambient. isFailure is event.isError === true and nothing else; a successful call writes nothing, a nameless tool writes nothing. Content is never read for value — only Array.isArray(result.content) + length to split refusal from error. The R2 mitigation is asserted, not promised: the tests plant a live-looking secret in args/result/content/details and grep every spool byte for the secret, Authorization, curl, cat , /etc/shadow and the toolCallId.
  4. /friction + tool + spool bounds. The command grammar and the JSON form both route through the one validator (a JSON payload with arguments is refused with no-arguments in 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. NullPublisher log-only default; JetStreamPublisher refuses loudly — honest posture for an unadmitted bus client.
  5. CI job. Well-formed and a one-line copy of the sibling pattern (working-directory, Node 22, npm ci from 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.
  6. Territory. 17 files: the ci.yaml hunk is purely additive (comments + one job) and everything else is new under pi-friction/. No pi-otel-telemetry/pi-swarm-suite/pi-skills file touched, no Plane writes, no compiler/sandbox-renderer change.

Non-blocking findings (may merge; none open as a blocker)

  • Fingerprint normalization is not exactly equivalent across the two implementations. TS normalizeKey strips [_\-/\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-tool where.ref (plain space+separator and all control chars are already refused by TS), and the failure mode is a loud fingerprint_mismatch refusal 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) writes actor.kind: "agent" with the session id, so the schema's human/id: operator shape (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 /friction from 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.
  • Doc nit: README "Testing" says 96 tests; it is 99.

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#592 diff (cross-impl fixture), CI run #118. Reviewing context ≠ implementing context; no implementer summary was used as the brief.

## Landing review — homelab-ticket pipeline (fresh context, primary sources only) **Verdict: APPROVE — no blocker findings.** Head reviewed: `76ace316d1986395387cfdb822c64f56255bb31f` (branch `plane/HOMELAB-1690-l7`, base `main`). CI: run #118 on `76ace316d1` **pass, 13/13** jobs incl. the new `pi-friction` job (1m0s). Independently re-ran the unit locally: `tsc --noEmit` clean, `vitest run` **99/99 pass (5 files)** — the "96 tests" line in `pi-friction/README.md` is stale (it is 99, which is what the ticket and CI comment cite). ### Dimensions checked 1. **No-arguments law (schema law).** Enforced in TS at four points as claimed: `scanForArguments` on forbidden key families at *any* depth (arrays walked, `a[0].arguments` asserted); the bare tool-name grammar `TOOL_NAME_RE` for `surface: tool`; `looksLikeArguments` for 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 + the `scanForArguments` array case = 17, all green. The auto path carries the authored-once structural pair and never claims `blocks` — the right call for a mechanism that cannot know whether the actor proceeded. 2. **Cross-impl alignment — verified against both sides, not the summary.** The pinned `ld.friction/v1` wire line is byte-identical in `pi-friction/tests/types.test.ts` and `infra-core#592` `line_test.go` (same `actor:{kind:human,id:operator,sandboxEnv:"unbound"}`, same fingerprint `09e84eddcf81d8d7`, asserted on both sides). The `sandboxEnv: unbound`-for-any-actor-without-a-sandbox rule is asserted independently on each side, so the divergence the builder reports really is closed. 3. **Auto-capture is failure-shaped, not ambient.** `isFailure` is `event.isError === true` and nothing else; a successful call writes nothing, a nameless tool writes nothing. Content is never read for value — only `Array.isArray(result.content)` + length to split refusal from error. The R2 mitigation is *asserted, not promised*: the tests plant a live-looking secret in `args`/`result`/`content`/`details` and grep every spool byte for the secret, `Authorization`, `curl`, `cat `, `/etc/shadow` and the `toolCallId`. 4. **`/friction` + tool + spool bounds.** The command grammar and the JSON form both route through the one validator (a JSON payload with `arguments` is refused with `no-arguments` in 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. `NullPublisher` log-only default; `JetStreamPublisher` refuses loudly — honest posture for an unadmitted bus client. 5. **CI job.** Well-formed and a one-line copy of the sibling pattern (`working-directory`, Node 22, `npm ci` from 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. 6. **Territory.** 17 files: the ci.yaml hunk is purely additive (comments + one job) and everything else is new under `pi-friction/`. No `pi-otel-telemetry`/`pi-swarm-suite`/`pi-skills` file touched, no Plane writes, no compiler/sandbox-renderer change. ### Non-blocking findings (may merge; none open as a blocker) - **Fingerprint normalization is not exactly equivalent across the two implementations.** TS `normalizeKey` strips `[_\-/\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-`tool` `where.ref` (plain space+separator and all control chars are already refused by TS), and the failure mode is a loud `fingerprint_mismatch` refusal 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) writes `actor.kind: "agent"`** with the session id, so the schema's `human`/`id: operator` shape (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 `/friction` from 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. - Doc nit: README "Testing" says 96 tests; it is 99. 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#592` diff (cross-impl fixture), CI run #118. Reviewing context ≠ implementing context; no implementer summary was used as the brief.
aaron merged commit a98178b043 into main 2026-10-03 03:57:11 +00:00
Sign in to join this conversation.
No reviewers
No labels
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/pi-extensions!61
No description provided.