Skip to content

feat: let plugins apply host changes only through an approved plan (§9.3) - #12

Closed
lr00rl wants to merge 2 commits into
feat/plugin-secret-storagefrom
feat/plugin-operation-execute
Closed

feat: let plugins apply host changes only through an approved plan (§9.3)#12
lr00rl wants to merge 2 commits into
feat/plugin-secret-storagefrom
feat/plugin-operation-execute

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #11#10#8#7. Depends on lattice-sdk #6 (typed Approval columns); go.mod points at that branch's commit — re-point to the tagged SDK release before merge.

Implements Bundle V2 §9.3 — the host-risk operation protocol. With #11 (secrets), the two prerequisites the spec names before any domain engine can leave core.

The PR has two commits: the protocol itself, then a refactor moving the binding from an in-Plan envelope to typed Approval columns (per review feedback — typed columns over a hashed blob). The description below is the final, typed design.

What was missing

A plugin could compile intent into a plan. It had no way to apply one, and no safe way to be given one: plan-effect methods were validated at load and then ignored at dispatch (their JSON went straight to the browser), and task:run was declared in the risk map with no broker surface behind it.

A plan is now a proposal

A plan-effect method returns a PluginOperationPlan. The server bounds it, authorizes every target against the principal who asked, and stores a pending approval. The operator reads the redacted preview and decides. Nothing is applied.

The binding is typed (not a hashed blob)

The approval carries the exact code and inputs that produced the plan as real columnsPluginVersion, ArtifactDigest, Service, Method, RequestSHA256, Targets — and Plan holds only the reviewable plan. At execute, each column is compared to live state:

bound column execute rejects if
PluginVersion the plugin was upgraded since approval
ArtifactDigest the artifact was re-signed
Service backing the service is no longer runtime-backed (engine moved to core)
Targets a target is no longer authorized for the approving principal
(active) the plugin was disabled

Targets are re-authorized against the approving principal, not the planner's — the person who approves is accountable. The columns are surfaced in ApprovalView, so a reviewer sees which version, which artifact, and which nodes will run.

This replaces the earlier "every field changes the plan hash" test with one test per column asserting a live-state mismatch refuses execution — proven with a nil runtime, so a gate that failed to fire would panic rather than silently pass.

The grant: task:run becomes eligibility, not authorization

Execute invokes the plugin with a one-time OperationGrant bound to the invocation host-side (mirroring InvokeConstraints.OperatorTargets). The plugin never receives it — it can only make a task.enqueue host call the broker checks against it:

attempt result
enqueue with no grant (reached via the ordinary gateway) refused
aim at an unapproved node refused
enqueue past the approval's budget refused
use another plugin's grant refused (broker stamps the verified plugin id)
hold a grant but never declared task:run refused

Server-side the task still passes validateTaskCreate and goes through queueTask, so a plugin can't reach a wider interpreter set or route around the fleet kill switch. The result ladder gains a generic branch so an operation's approval reaches applied when its task succeeds.

It all rests on one property

execute is reachable from exactly one place — the approval executor. TestExecuteActionIsInvokedOnlyByTheApprovalExecutor fails if any other file names the action, and TestDiagnosticActionsNeverIncludeExecute keeps it out of the plugin:admin-only invoke channel.

Test plan

  • go vet ./internal/... clean
  • go test ./internal/plugin/ ./internal/server/ ./internal/store/ -race — all green
  • Per-column re-check tests (version / digest / backing / disabled / target)
  • Grant tests (no grant / unapproved node / exhaustible / non-transferable / capability)
  • Reachability guards + canonical-plan determinism
  • Manual end-to-end once a plugin ships an execute action — see lattice-plugin-template (reference execute implementation).

https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78

lr00rl added 2 commits July 14, 2026 05:54
Spec §9.3, and the last thing standing between a domain engine and core.

A plugin could already compile intent into a plan. It had no way to apply one —
and no safe way to be given one. `plan`-effect methods were validated at load and
then ignored at dispatch: a plan-effect call returned whatever JSON the plugin
felt like emitting, straight to the browser, with nothing to approve and nothing
bound. Meanwhile `task:run` was declared in the risk map with no broker surface
behind it, so a plugin could hold it and it granted nothing.

Now a plan is a proposal. A plan-effect method returns a PluginOperationPlan; the
server bounds it, authorizes every target it names against the principal who asked
for it, and stores a pending approval. An operator reads the redacted preview and
decides.

The binding is the point. An approval recording only "plugin X, action Y" can be
honoured by a different version of X, by a re-signed artifact, against nodes the
reviewer never saw, or after the plan was rewritten underneath it. So the approval
carries a canonical envelope naming the plugin, its version, the exact artifact
digest, the service, the method, a hash of the request that produced the plan, and
the targets. That envelope IS the approval's plan text, so the existing plan-hash
gate covers all of it: the operator approves a hash, and the hash is the whole
tuple. A test mutates each field in turn and fails if any of them can move without
moving the hash.

Execution re-checks every one of those, because all of them can change between
approval and apply, and re-authorizes the targets against the APPROVING principal
— the planner's scopes are not inherited, because the person who approves is the
person who is accountable.

Only then is the plugin invoked with `execute`, carrying a one-time OperationGrant
bound to the invocation on the host side. The plugin never receives it: it cannot
read it, forge it, or widen it. It can only make a task.enqueue host call that the
broker checks against it. So task:run stops being authorization and becomes mere
eligibility — the grant says which work, on which nodes, under which approval, and
how many times. Reached without a grant, or aimed at an unapproved node, or past
its budget, or holding another plugin's grant, the enqueue is refused.

Server-side the task still passes validateTaskCreate and goes through queueTask, so
a plugin cannot reach a wider interpreter set, a longer timeout, or a bigger script
than an operator could, and cannot route around the fleet kill switch.

All of this rests on one property: `execute` is reachable from exactly one place.
It is invoked in a single non-test file, the approval executor, and a structural
test fails if any other file so much as names the action — alongside one asserting
it never enters the /api/plugins/invoke allow-list, which is gated on plugin:admin
alone and would otherwise let one scope apply host changes unreviewed.

Approval lives in lattice-sdk and lacks seven of the nine fields this needs. Rather
than fork the SDK, the tuple lives inside the canonical envelope, which achieves
the same cryptographic binding through the plan hash. That shortcut is only safe
because the mutation test above holds it honest; it should not be deleted.

Tests: go vet clean; plugin, server and store green under -race.
The first cut of §9.3 packed the whole binding tuple — plugin id, version, artifact
digest, service, method, request hash, targets — into the free-form Approval.Plan
string and leaned on the plan hash to bind it. That works, but it makes the binding
a property of "the hash of a JSON blob" rather than of individual fields, and it
hides in Plan what an operator and an auditor should see plainly.

lattice-sdk now gives Approval real columns for these (PluginVersion, ArtifactDigest,
Service, Method, RequestSHA256, Targets). Plan holds only the reviewable plan the
operator reads; the binding lives in typed fields that execution compares to live
state one by one. Each mismatch — plugin upgraded, artifact re-signed, service no
longer runtime-backed, target no longer authorized — refuses execution before the
plugin is invoked, and each is surfaced in ApprovalView so a reviewer sees which
version, which artifact, and which nodes will actually run.

The test that made the old shortcut safe (every-field-changes-the-plan-hash) is
replaced by something more direct and closer to the spec: one test per bound column
asserting a live-state mismatch refuses execution, proven with a nil runtime so a
gate that failed to fire would panic rather than pass. The approval result ladder
gains a generic plugin-operation branch, so an operation's approval reaches `applied`
when its task succeeds instead of sitting in `approved` forever.

go.mod points at the lattice-sdk branch commit carrying the new columns; re-point to
the tagged SDK release before merge.

Tests: go vet clean; plugin, server and store green.
@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

[ack] — zeus review, Olympus TASK-0001 item 4 (2026-07-26)

§9.3 verified at the integration tip: plan-effect output becomes a bounded pending approval (nothing applied); typed columns re-checked per-column against live state at execute; targets re-authorized against the APPROVING principal (ApprovedBy = p.ActorID at the decision handler, network:apply at execute vs network:plan at plan); one-time grant bound host-side and non-transferable (broker stamps the verified plugin id); enqueue still passes validateTaskCreate/queueTask so the fleet kill switch holds; execute reachable only from the approval executor — pinned by both reachability tests and dependent on #7's closed diagnostic list (verified composition).

Evidence: contained in integration (86422a1); full -race -cover green (internal/server 397.6s @ 69.8%). sdk typed columns present on both sdk lines; server pin sdk@4a318f2 verified to contain them. Disposition: close-with-landing-commit per rules/01 §8.5; manual e2e still owed once a plugin ships execute (template#3 reference — TASK-0007 scope).

@lr00rl

lr00rl commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): commit e3de759 contained in integration (86422a1). [ack] verdict above; manual e2e owed via template#3 reference flow. Closing, not rejecting.

@lr00rl lr00rl closed this Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant