Skip to content

The automation plane gets its own token, and the host enforces the boundary

Date: 2026-08-12 Status: open Supersedes: none Superseded-by: none — current

INV-3 of PTV-SCF-0001 is fatal: “The automation plane (n8n) is a C4 observer and C2 composer, never a C3 mutator. Any generated workflow that can apply a verb, merge, or push is a defect regardless of how well it works.” (docs/specs/2026-08-12-ptv-scf-0001-scaffold-rev-a.xml.)

Conformance has been tracked on one axis — the n8n management credential, measured owner-grade on 2026-08-12 (POST /api/v1/workflows200, docs/decisions/2026-08-12-ptv-sr-0015-automation-plane-boundary.md:52-85).

There is a second axis, and it had not been recorded. An n8n flow reads the estate through petrova.host, and petrova.host authenticates with a single shard token: host/src/config.ts takes exactly one PETROVA_HOST_BEARER, and dashboard/src/pages/api/mcp.ts:26-32 states in its own header that the token “grants the whole surface, mutating verbs included.” Handing that token to a flow grants all 42 tools, the 17 mutating ones included — 16 petrova.act.* write verbs plus petrova.sweeps.trigger.

So INV-3 was breached by the credential regardless of flow content, on an axis the n8n account question does not touch. The same header pushed the remedy onto the caller: “a consumer that wants a read-only surface must deny the mutating tools on its side.” That is a convention, not a boundary. P5 is required to attack exactly this shape — refusals must hold “by credential scope or CI, not by the prompt asking nicely” — and a boundary the caller enforces fails that test by construction.

Add PETROVA_HOST_READONLY_BEARER: an optional second token resolving to a readonly scope in which tools/list omits the mutators and any call to one is refused.

Four properties, each load-bearing:

  1. Derived from the mutating flag, never from the name. MUTATING_TOOLS (host/src/registry.ts) already filters on the flag. petrova.sweeps.trigger mutates — it fires a workflow_dispatch — and carries no act. prefix, so a prefix filter leaks a dispatch to the plane. There is a test whose only job is to fail if someone rewrites the filter that way.
  2. Enforced on the call, not only in the listing. A caller that knows a tool’s name never reads tools/list. Refusal sits before the handler lookup.
  3. All three transports, one resolver. resolveScope lives in host/src/auth.ts and is called by dashboard/src/pages/api/mcp.ts, dashboard/src/pages/api/rpc.ts, and host/src/http.ts (the Railway server, easy to miss — it has its own bearer check). This repo has lost tools to a forked registry three times; a scope check implemented three times would drift the same way.
  4. Opt-in and fail-closed. Absent config leaves behaviour identical. A read-only bearer configured equal to the full bearer throws at load rather than serving: it would grant the whole surface to a caller every artefact described as read-only, and the scope check would pass on the wrong branch with nothing downstream able to detect it.

The refusal is JSON-RPC -32601, not MCP’s isError. isError means the tool ran and failed; a client retrying on that would be retrying a refusal.

This does not make the estate INV-3-conforming. It closes one of three axes. The n8n management key is still owner-grade, and INV-1’s ambient estate-wide gh token is untouched.

  • Deny on the consumer side, as the header advised — rejected. It is the status quo, it is a convention rather than a capability, and P5’s self-improvement attack is specified to defeat exactly this. It also scales wrongly: every future consumer would re-implement the deny list, and one that got it wrong would be indistinguishable from one that got it right until it fired a verb.
  • Per-tool API keys / full RBAC — rejected for now as disproportionate. The requirement is one narrower principal, not an authorisation system, and a scope enum invites a policy engine nobody asked for. Revisit if a second distinct consumer needs a third surface.
  • A separate read-only deployment of the host — rejected: it doubles the deploy surface and the state/ staleness problem (D-09/D-12) with it, to express something a token already expresses.
  • Filter on the petrova.act. prefix — rejected, and recorded here because it is the attractive wrong answer: it reads as equivalent, is shorter, and silently exempts petrova.sweeps.trigger.

For code:

  • host/src/auth.ts gains resolveScope and the AuthScope type; verifyBearer is unchanged and still used by it.
  • host/src/config.ts gains optional readonlyBearer, and throws when it equals bearer.
  • host/src/registry.ts gains READONLY_TOOLS_LIST and isMutating, both derived.
  • dashboard/src/pages/api/rpc.ts gains its first tests. It had none, and it is the transport the CLI actually speaks.

For operations:

  • The token must not be added to ~/code/env/_loader.zsh. Loading it into every interactive shell is what made C-1 ambient, which is the condition INV-1 halts on.
  • Vercel remains the source of truth for both bearers; a rotation needs a redeploy, as today.

For the scaffold:

  • P4’s prompt flows can now run “under plan mode with a read-only credential” as specified. Before this they could not, on any credential that exists.
  • P5 gains a refusal it can attack by scope. The negative test — a mutating call under the read-only bearer, refused — is a deliverable there, not an assertion here.

For invariants: INV-3 moves from breached on three axes to breached on two. No MR is added, modified or repealed.

  • docs/specs/2026-08-12-ptv-scf-0001-scaffold-rev-a.xmlINV-3, and P4/P5 gate text.
  • docs/decisions/2026-08-12-ptv-sr-0015-automation-plane-boundary.md — the boundary law, and the owner-grade measurement on the n8n axis.
  • docs/findings/20260811-2027-ptv-scf-0001-credential-scope.md — C-1, C-3, C-5 and the ambient-credential problem _loader.zsh creates.
  • host/src/registry.tsmutating flag, MUTATING_TOOLS, and the header explaining why every transport derives from one registry.
  • Subagent: PTV-SCF-0001 Phase A (session 2026-08-12)
  • Human countersign — that the automation plane authenticates with a distinct read-only bearer, that the token is never loaded by _loader.zsh, and that this closes one INV-3 axis without making the estate conforming