feat: let plugins apply host changes only through an approved plan (§9.3) - #12
feat: let plugins apply host changes only through an approved plan (§9.3)#12lr00rl wants to merge 2 commits into
Conversation
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.
|
[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 ( Evidence: contained in |
|
Closed as landed — zeus, operator-approved sweep (2026-07-26, rules/01 §8.5): commit |
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-
Planenvelope to typedApprovalcolumns (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), andtask:runwas declared in the risk map with no broker surface behind it.A plan is now a proposal
A
plan-effect method returns aPluginOperationPlan. 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 columns —
PluginVersion,ArtifactDigest,Service,Method,RequestSHA256,Targets— andPlanholds only the reviewable plan. At execute, each column is compared to live state:PluginVersionArtifactDigestServicebackingTargetsTargets 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:runbecomes eligibility, not authorizationExecute invokes the plugin with a one-time
OperationGrantbound to the invocation host-side (mirroringInvokeConstraints.OperatorTargets). The plugin never receives it — it can only make atask.enqueuehost call the broker checks against it:task:runServer-side the task still passes
validateTaskCreateand goes throughqueueTask, 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 reachesappliedwhen its task succeeds.It all rests on one property
executeis reachable from exactly one place — the approval executor.TestExecuteActionIsInvokedOnlyByTheApprovalExecutorfails if any other file names the action, andTestDiagnosticActionsNeverIncludeExecutekeeps it out of theplugin:admin-only invoke channel.Test plan
go vet ./internal/...cleango test ./internal/plugin/ ./internal/server/ ./internal/store/ -race— all greenexecuteaction — see lattice-plugin-template (referenceexecuteimplementation).https://claude.ai/code/session_01FzYExyy6G7Cb7QpXoUEY78