From 5411afd8b349d137e39d7dcbe1b27ef452d06a5e Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:00:54 +0000 Subject: [PATCH 1/9] Plan thread F: classify every route, sign the upgrade channel, and remove the enforcement switch Seven slices. F1 gives every catalog route an authorization class a test checks, and refuses Cloud's Leave and Forget under enforcement (#4049). F2 admits upgrades only along a channel the customer signed. F3 keeps ingress and Private Access running under enforcement. F4 signs the dashboard deploys B5 left. F5 puts teardowns under approval. F6 turns enforcement on once per Cluster with the 0.4 upgrade. F7 removes the switch, so a fresh daemon refuses a join no one approved. --- docs/agents/plans/security-0.4/f-inventory.md | 116 +++++ docs/agents/plans/security-0.4/f-plan.md | 459 ++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 docs/agents/plans/security-0.4/f-inventory.md create mode 100644 docs/agents/plans/security-0.4/f-plan.md diff --git a/docs/agents/plans/security-0.4/f-inventory.md b/docs/agents/plans/security-0.4/f-inventory.md new file mode 100644 index 0000000000..6eec13501f --- /dev/null +++ b/docs/agents/plans/security-0.4/f-inventory.md @@ -0,0 +1,116 @@ +# Thread F inventory: route classes, automatic effects, unsigned paths and the switch + +This file lists, as of `main` at `02aaa926b5`, every Machine route with the authorization class [f-plan.md](f-plan.md) gives it. It also lists every automatic effect and every Cloud path still sent unsigned, plus every place the enforcement switch lives. Paths are relative to `core/crates/` unless they start with `dashboard/`. "Today" means what an Operator's call does on a Machine with Device enforcement on. + +A route **crosses the bar** when a compromised Cloud can use it, under enforcement, to read secrets, run code of its choosing on a Server, take data off it, or add a Server or Device. A route marked **residual** lets Cloud mislabel, reorder, delay or drop and nothing more. The plan names that once. + +## Every route + +`Signed(…)` lists the statements that admit an Operator's call. `Signed()` with nothing listed means no statement admits it yet, and only a Full caller can call it. The class column is the class F1 writes. A different class later names the slice that moves it. + +### Reads + +These change nothing on the Machine. Class `Read`. `RetainedEnv` answers opened values only as ciphertext (`ployzd/src/sealed_env.rs:773`). Logs and metrics are data Cloud already sees, a residual the decision record names. + +`DescribeContract`, `Inspect`, `PendingJoins`, `ListMachines`, `ListContainers`, `InspectContainer`, `InspectStorage`, `ListVolumes`, `InspectVolumeCopy`, `ListImages`, `InspectBuildGrant`, `ReadRoutes`, `ReadRoutedAddresses`, `ReadClusterName`, `MetricHistory`, `RetainedEnv`, `LogHistory`, `TailLogs`, `RuntimeWatch`. + +### Full scope + +Refused at dispatch for any caller but the local socket or a Device's own connection (`ployzd/src/machine_api/mod.rs:212`). Class `Full`. + +`WrapSealingKey`, `RecoverSealingKey`, `SetDevice`, `SetPasskey`, `RemoveTrustedKey`, `SetDeviceEnforcement`, `Exec`. + +`SetDeviceEnforcement` keeps `Full`. F6 refuses `enforced: false` once a Machine is activated, and F7 makes "off" unreachable. + +### Mutating routes + +| Route | Capability | Today under enforcement | Class at F1 | Later | +| --- | --- | --- | --- | --- | +| `Enroll` | `ployz.machine.enroll.v1` | A member needs an approved capsule, and a founder is refused (`local_machine.rs:811`). **A fresh daemon is not enforcing**, so it takes Cloud's Devices when the capsule is withheld (`machine/store.rs:551`). A1's accepted residual. | `Pending("F7")` | `Signed(Join)` at F7, plus a first key for a founder (gap 6) | +| `EnrollForRecovery` | `ployz.machine.enroll-for-recovery.v1` | A fresh daemon founds a Cluster awaiting recovery. It holds no key until `RecoverSealingKey`, which is `Full`. | `Pending("F7")` | As `Enroll`'s founder at F7 | +| `Register` | `ployz.machine.register.v1` | Needs a join approval. Publishes only after the joiner's confirmation (#4025). | `Signed(Join)` | | +| `ApproveJoin` | `ployz.machine.join.approve.v1` | Carries the Device's signature | `Signed(Join)` | | +| `ShareSealingKey` | `ployz.sealing.share.v1` | Needs a join approval (`local_machine/sealing.rs:162`) | `Signed(Join)` | | +| `GenerateSealingKey` | `ployz.sealing.generate.v1` | Only when the Cluster publishes no key. The key stays on the Machine. | `Bound("the key never leaves the Machine")` | | +| `InstallSealingKey` | `ployz.sealing.install.v1` | Opens only a key sealed by an accepted Machine's WireGuard key (`sealing.rs:262`) | `Bound("Cloud holds no Machine's key to seal from")` | | +| `SetManagementClient` | `ployz.machine.set-management-client.v1` | Refused from the mesh under enforcement. Through Cloud's own connection it mints another Operator credential. Device slots are reserved. | `Bound("mints only Operator authority Cloud already has")` | | +| `SetPushTarget` | `ployz.machine.set-push-target.v1` | Sets where crash events go | `Bound("crash events are data Cloud already reads")` | | +| `SelectEndpoint` | `ployz.network.select-endpoint.v1` | Chooses a WireGuard path. Peers stay key-bound. | `Bound("residual: delay or drop")` | | +| `UpdateMachine` | `ployz.machine.update.v1` | Name, labels, endpoints and policy flags | `Bound("residual: mislabel; approvals bind IDs")` | | +| `SetClusterName` | `ployz.cluster.name.v1` | A label | `Bound("residual: mislabel")` | | +| `RequestMachineUpgrade` | `ployz.machine.upgrade.request.v1` | Any channel, or an exact version either channel resolves to (`installer/upgrade.rs:159`) | `Bound("signed releases only, forward only")` | `Signed(Upgrade)` at F2 | +| `Leave` | `ployz.machine.leave.v1` | **Admitted.** Removes every managed Container, departs storage and resets the daemon to a fresh, unenforced one (`local_machine.rs:1141`, `machine/store.rs:940`). | `Signed()` | `Signed(Teardown)` at F5 | +| `Forget` | `ployz.machine.forget.v1` | **Admitted.** Deletes another Machine's row, and that Machine fences itself the same way, keeping its copies sealed (`local_machine.rs:1107`, `forgotten_fence.rs:238`). | `Signed()` | `Signed(Teardown)` at F5 | +| `EnsureContainer` | `ployz.container.ensure.v1` | Creations need a deploy approval. Lifecycle steps need a restart, deploy, proposal or rule approval. | `Signed(Deploy, Restart, Proposal, Rule)` | `Teardown` added at F5 | +| `UpdateContainerLimits` | `ployz.container.limits.update.v1` | CPU and memory limits | `Bound("residual: a limit can slow or stop a Service, a drop")` | | +| `PrepareVolumes` | `ployz.storage.prepare.v1` | Creates datasets ahead of a Deploy. Thread C checks a Volume when a Container mounts it. | `Bound("checked at mount by C")` | | +| `RemoveVolume` | `ployz.volume.remove.v1` | **Admitted.** Deletes a Docker Volume. Data loss, not a bar crossing. | `Pending("E6")` | `Signed(Volume)` at E6 (gap 5) | +| `SeedVolume` | `ployz.volume.seed.v1` | Refused (E1) | `Signed(Volume)` | Admitted under approval at E7 | +| `SetVolumeCopy` | `ployz.volume.copy.set.v1` | A Writer want with a start is refused (E1). Pulls, cuts, promotes and forgets are admitted. `Floor` and `Snapshot` are residuals. | `Pending("E3 to E6")` | `Signed(Volume)` at E6 | +| `SetVolumeSize` | `ployz.volume.size.set.v1` | Admitted. A shrink below what the copy holds is refused by the Machine. | `Pending("E7")` | `Signed(Volume)` at E7 | +| `PruneImages` | `ployz.image.prune.v1` | Removes unreferenced images | `Bound("residual: the next deploy pulls again")` | | +| `EnsureImageIngest` | `ployz.image.ingest.ensure.v1` | Opens an image push. An image runs only when a deploy approval names its digest. | `Bound("content-addressed; checked at creation by C")` | | +| `PullImage` | `ployz.image.pull.v1` | As above | `Bound("content-addressed; checked at creation by C")` | | +| `PullImageFromMachine` | `ployz.image.pull-from-machine.v1` | As above | `Bound("content-addressed; checked at creation by C")` | | +| `MintBuildGrant` | `ployz.build.grant.mint.v1` | Refused unless it carries a rule and proof (D1, D3) | `Signed(Rule)` | `BuildApproval` added at D6 | +| `Build` | `ployz.build.v1` | Refused (D1) | `Signed()` | | +| `PublishCertificateMaterial` | `ployz.certificates.publish.v1` | Admitted. Sets the key the Ingress Proxy serves for a hostname. | `Bound("DNS and ACME authority Cloud holds; thread X")` | | +| `SetIngressDoors` | `ployz.ingress.doors.v1` | Refused (`machine_api/local.rs:1167`) | `Signed()` | `Bound("daemon-pinned images; tunnel token is thread X's")` at F3 | +| `SetPrivateAccess` | `ployz.private-access.v1` | Refused (`machine_api/local.rs:1179`) | `Signed()` | `Signed(PrivateAccess)` at F3 | + +## Automatic effects + +Each runs with no person present. "Class" is the route class it rides, or "local" for work the daemon does without an RPC. + +| Effect | Who drives it | Route | Under enforcement today | After F | +| --- | --- | --- | --- | --- | +| Ingress startup and repair | Cloud (`dashboard/ingress/doors.server.ts`), and a joiner's own daemon during global catch-up | `SetIngressDoors` | Refused through Cloud | `Bound` at F3 | +| Certificate renewal | The daemon's Caddy | Local | Runs | Unchanged. F3 lane 4 and F6 lane 9 check it with Cloud stopped. | +| Certificate material for Cloud-managed hostnames | Cloud (`dashboard/cluster-domain/sync.server.ts`) | `PublishCertificateMaterial` | Runs | `Bound`, thread X | +| Image pruning | The deploy runner after a Deploy | `PruneImages` | Runs | `Bound` | +| Preview cleanup | Cloud on pull request close (`dashboard/machines/namespace-cleanup.*`) | `EnsureContainer` (`Out`, `Remove`), `RemoveVolume` | Container removal is refused. Volume removal runs. | Under D7's preview rule at F5 (gap 4) | +| Deleted Environment cleanup | Cloud (`dashboard/machines/namespace-cleanup.*`) | As above | As above | Namespace teardown at F5 | +| Automatic Mirror sync | Cloud (`dashboard/volume-placement/mirror-sync.server.ts`) | `SetVolumeCopy` | Runs unsigned | Mirror's standing approval (E3) | +| Orphan sweep | Cloud (`sweepOrphans`) | `SetVolumeCopy` `Forget` | Runs unsigned | The approval that orphaned the slot (E6) | +| Volume recovery after a stalled Move | Cloud (`releaseVolume`, `finishRelease`) | `SetVolumeCopy` | A start is refused (E1) | A Release under the Move's own approval (E5) | +| Global catch-up on a joiner | The joining CLI over the joiner's local socket (`ployz/src/global_catch_up.rs`) | `EnsureContainer` as a Full caller | Runs | Unchanged. It is a Full caller on the joiner itself. | +| Automatic upgrades | Cloud's rollout (`dashboard/server-upgrade/server-upgrade.server.ts:212`) | `RequestMachineUpgrade` | Runs along any channel | Along the signed channel at F2. Never 0.3 to 0.4 at F6. | +| Forgotten-Machine fence | The forgotten Machine's own daemon, on its row's removal | Local, after `Forget` | Runs on any `Forget` | Only after a signed `Forget` reaches another Machine (F1, F5) | + +## Cloud paths still sent unsigned + +Each is refused on an enforcing Machine today, so each is a flow a customer loses by turning enforcement on. + +| Path | Where | Signed by | +| --- | --- | --- | +| **Redeploy** | `dashboard/config-store/**` | F4 | +| Rollback of an image Service | `dashboard/service-act/**`, `dashboard/config-store/**` | F4 | +| Rollback of a Git Service | as above | D6's `BuildApproval`, wired in F4 | +| **Retry** | `dashboard/config-store/**`. The Store has no approval field yet. | F4 | +| A Sync's deploy | `dashboard/config-store/**` | F4 | +| A new branch's first deploy | `dashboard/config-store/**` | F4 | +| Environment deletion, preview close | `dashboard/machines/namespace-cleanup.*` | F5 | +| Server removal and forgetting | `dashboard/machines/machine-removal.*`, `forget-servers.*` | F5 | +| Upgrades | `dashboard/server-upgrade/**` | F2 | +| Ingress doors, Private Access | `dashboard/ingress/**`, Private Access settings | F3 | +| Move, Mirror, Restore and other Volume Runs | `dashboard/volume-run/**` | E3 to E8 | +| The old dispatched GitHub build workflow | `dashboard/github/**`, `dashboard/config-store/store-github*` | Deleted at F7, per d-plan's handoff | + +## Where the switch lives + +F7 deletes each `false` branch, or the field's use, below. Found with `rg -l device_enforcement core/crates` outside tests, and `rg -il 'enforce' dashboard/src/modules` outside tests. + +| File | What reads the switch | +| --- | --- | +| `ployzd/src/machine/mod.rs`, `ployzd/src/machine/record_wire.rs` | The record field and its wire form. F7 keeps the read on first start only. | +| `ployzd/src/machine/store.rs` | `member`'s capsule-less branch (`:551` to `:559`), `install`, `set_device_enforcement` (`:902`) | +| `ployzd/src/machine_api/mod.rs` | Full scope for a mesh caller when not enforcing (`:185`) | +| `ployzd/src/machine_api/local.rs` | Each `from_operator` check that passes when not enforcing | +| `ployzd/src/machine/local_machine.rs` | Founder refusal (`:811`), join confirmation (`:872`), `register` (`:920`) | +| `ployzd/src/machine/local_machine/{restart_approval,deploy_approval,volume_approval,sealing,join_approval,container}.rs` | Each `admit_*` that returns early when not enforcing | +| `ployzd/src/management/{mod,build_grant}.rs` | Grant admission and serving | +| `ployzd/src/corrosion/store.rs` | The peer filter (`:736`) and the runtime report (`:85`) | +| `ployz-core/src/{rpc,rpc_catalog,domain/machine}.rs` | `SetDeviceEnforcementRequest.enforced`, the capability, the runtime field | +| `ployz/src/handlers/device.rs` | `enforce --off` (`:91`) | +| `ployz/src/handlers/deploy/approve.rs` | Signing only when a Machine enforces | +| `dashboard/src/modules/{service-act,approval-session,approvals,proposals,volume-run,runtime,machines}/**` | Choosing the unsigned path for a Machine that does not enforce | diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md new file mode 100644 index 0000000000..ba94113730 --- /dev/null +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -0,0 +1,459 @@ +# Security 0.4 thread F plan, coverage, upgrades, maintenance and cutover + +A compromised Cloud must not read secrets, run code of its choosing on a Server, take data off a Server, or add a Server or Device. Threads A to E close the routes that cross that bar, one at a time, behind a per-Cluster enforcement switch. Thread F makes the result whole. Every mutating route gets an authorization class that a test checks. Upgrades and automatic maintenance keep running. The 0.4 upgrade turns enforcement on once per Cluster with the customer's authority. In the last slice the switch is gone. Enforcement is then the only mode, and a fresh daemon refuses a join no one approved. + +Two things are open on `main` today. An enforcing Machine still obeys Cloud's `Leave` and `Forget`. Either one removes every Container on a Server and resets it, with no person involved (#4049). F1 closes that. And a fresh daemon that Cloud cuts off from its join capsule enrolls with enforcement off, trusting Devices Cloud chose. Thread A accepted that residual until F removes the switch (#4025). F7 closes that. + +[f-inventory.md](f-inventory.md) lists every catalog route with its class, every automatic effect, every Cloud path still sent unsigned, and every place the switch lives. + +Seven PRs, in dependency order: + +| Slice | Outcome | Closes | +| --- | --- | --- | +| F1 | Every catalog route has an authorization class, checked by a test. Enforcing Machines refuse Cloud's `Leave` and `Forget`. | A new route landing with no class. Cloud resetting an enforcing Server with no approval (#4049). | +| F2 | A Machine upgrades through Cloud only along the Release Channel the customer signed | Cloud choosing a release off the customer's channel | +| F3 | Ingress doors start, and Private Access turns on, under enforcement | Maintenance that today only works with enforcement off. A Cloud-chosen tailnet reaching the mesh. | +| F4 | Redeploy, rollback, Retry, a Sync's deploy and a new branch's deploy are signed in the browser | The unsigned dashboard deploys B5 left. Each is refused under enforcement today. | +| F5 | Deleting an Environment, closing a preview and removing a Server run under a signed teardown | Cloud tearing down a Namespace or a Server on its own word | +| F6 | The 0.4 upgrade ends in one activation per Cluster, which can't be turned off | The trust-on-first-use starting set (#3945). A Server too old to enforce in an enforcing Cluster (#3980, and A1's older-admitter window). | +| F7 | The switch is gone. A fresh daemon refuses an unapproved join, and a new Cluster enforces from its first Server. | The capsule-less join (A1's residual). Every unsigned path in daemon and Cloud. | + +## Operator decisions + +Three. None blocks F1. Each has one recommendation. + +1. **Per-upgrade approval or a standing channel.** Automatic upgrades run with no person present (`docs/user/servers/manage-servers.md`, "Turn on automatic upgrades"). An approval per upgrade would end them on enforcing Clusters. *Recommendation.* A standing channel. A person signs "this Cluster follows Stable" (or Beta) once, from a Device or with one passkey prompt, and signs again only to switch. Each Machine admits an upgrade from Cloud only to the release that channel resolves to, which the Machine reads itself. Cloud still picks when to upgrade and in what order, which is a delay or reorder under the bar. +2. **How 0.4 squares with the stable promise.** The promise says a changed route semantic gets a new `.vN` route, and the old row serves until a major. Refusing an unsigned `EnsureContainer` changes the semantic of a row that 0.3 serves. *Recommendation.* A narrow, dated 0.4 exception, written into `core/DESIGN.md` by F7, with the text in "The stable-promise argument" below. Every 0.4 daemon refuses mutating Operator routes that carry no customer authority, naming `details.capability` as rule 7 already requires. A Machine still upgrades in place without re-enrolling, and every row and record stays readable. The alternative is additive activation by capability name. It needs no exception, but it keeps the unsigned paths in daemon and Cloud until 1.0 for every Cluster that never activates. That is the permanent mixed-version dispatcher the decision record rules out, and it leaves those Clusters fully exposed. +3. **What "refuses an unapproved join" can promise.** Deleting the capsule-less path stops a joiner from enrolling with no approval. A fresh daemon still has nothing to check an approval against, so Cloud could forge one under its own Sealing Key and Device. *Recommendation.* Joins driven by a person's CLI (`ployz server add` from an approved Device) pass the joiner a pin of the Cluster's authority over SSH, and the joiner refuses any approval that does not chain to it. A join from Cloud's install line runs Cloud's script as root before anything else, so a pin there proves nothing. F7 names that residual once instead of building a check that can't hold. + +## How to read this + +Same conventions as `c-plan.md`, `d-plan.md` and `e-plan.md`. One box is one unit of work, and every box names the evidence that checks it. Check a box only when that evidence exists. Tests alone are not sufficient verification. A PR is verified only when its unit, live and perf boxes are all checked. + +Terms follow `core/CONTEXT.md` (Machine, Device, Passkey, Release Channel, Sealing Key, Management Scope, Operator) and `dashboard/CONTEXT.md` (Server, Approval card, Environment). "Authorization class", "upgrade channel statement", "teardown", "activation" and "awaiting activation" are new and defined in "Design". "The switch" means `device_enforcement` in the local Machine record and everything that reads it. No log, test, fixture or screenshot prints a secret value. Nothing written for this program names a Cloud host, an address or a path on Cloud's servers. + +The program plan is not on `main`. Read it with `git show 71806c2db4:docs/agents/plans/security-0.4/plan.md` (section F, checkpoints C4 and C5) and the decision record beside it. + +## Design + +### Authorization classes + +Each catalog route gets exactly one class, in a table in `ployz_core::authority::coverage`, keyed by the generated capability constants. + +| Class | Meaning | An Operator's call on an enforcing Machine | +| --- | --- | --- | +| `Read` | Changes nothing on the Machine | Served | +| `Full` | Catalog scope `Full` | Refused at dispatch (`machine_api/mod.rs:212`) | +| `Signed(statements)` | Needs a statement a trusted key signed. The list names which statements admit it, and it may be empty. | Refused without one, naming the route's capability | +| `Bound(reason)` | Served unsigned. The reason says why the worst Cloud can do is mislabel, reorder, delay or drop, or an effect that crosses no part of the bar. | Served | +| `Pending(slice)` | Not gated yet. The slice that gates it is named. | Served, as today | + +`Pending` is a migration state. F7 deletes the variant, so the build fails while any route still has it. That is how "every mutating route has a class" stays true after F. A new route cannot reach `main` without a class, because the completeness test fails, and `Pending` needs an edit to a frozen list. + +Two tests hold the table to the code. A static test in `ployz-core` checks that every catalog route appears exactly once, that `Full` matches the catalog's scope column both ways, and that the `Pending` list equals a frozen list. A behaviour test in `ployzd` drives each `Signed` route through the real `MachineApi` as an Operator on an enforcing Machine, with a valid request that carries no statement. It checks the refusal names `details.capability` and that nothing changed. A `Signed` route without a sample request fails the test. + +### The upgrade channel + +One statement, `UpgradeChannel`, in `ployz_core::authority::upgrade`, signed under `Purpose::Rule`, so a session grant cannot sign it. It binds the Cluster's Sealing public key, the channel (`stable` or `beta`), and a generation. It names no Machines and no release, and it does not expire. + +Cloud stores the signed statement with the Organization's upgrade settings and attaches it to every `RequestMachineUpgrade` as an optional field. On an enforcing Machine, an Operator request is admitted when: + +- the statement verifies against this Machine's trusted keys and names this Cluster; +- its generation is at least the highest this Machine has accepted, which it then records; +- the request names that channel, or an exact version equal to what that channel resolves to now. + +The Machine resolves the channel itself, as it does today (`installer/release.rs:126`), so the pointer read and the minisign check stay where they are. The downgrade refusal (`installer/upgrade.rs:130`) is unchanged. A Full caller keeps `AnyRelease`. + +This only promises something if Ployz Cloud's operator cannot produce a signed release. That is Y's action O1 on the release repository. It means a `v*` tag ruleset, required reviewers on the `release` environment, and no write-capable App installation on the organization. The root confirms O1 is in place before F2 merges, and F2's PR body says so. + +### Teardowns + +A teardown is a `Workflow` statement that names what goes and what is lost. It comes in three kinds. + +| Kind | Names | Permits | +| --- | --- | --- | +| Namespace | The Cluster, the Namespace, the Machines, the Volumes lost | `Out` and `Remove` of every Container in that Namespace on those Machines, and `RemoveVolume` of the named Volumes there | +| Preview | Covered by D7's preview rule, not signed again | The same, for the rule's derived Namespace when its pull request closes | +| Server | The departing Machine, the Machines told to forget it, whether its copies are kept | `Leave` on the departing Machine, `Forget` on the others | + +The existing data-loss confirmation stays first, and what the person confirmed is inside what they sign, as E6 does for Volumes. A dashboard teardown uses the passkey's own prompt, not a session. A Device's CLI signs with no prompt. + +### Activation + +Activation is today's `ployz device enforce` with three changes. The starting set is gathered over a path Cloud cannot rewrite, closing #3945. The set refuses any Machine that does not advertise `ployz.machine.enforced.v1`. And there is no `--off`. + +`ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, and when it publishes an approved joiner only after the joiner's confirmation (#4025). Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. + +For a dashboard-only customer, activation is the last step of the guided 0.4 upgrade. Cloud does not roll a Cluster from 0.3 to 0.4 automatically. + +```mermaid +flowchart LR + A[0.3 Cluster] -->|guided upgrade, one Server at a time| B[0.4 daemons, awaiting activation] + B -->|one approval: starting set from a Device or passkey| C[Activated: enforcing, no off] + D[Fresh 0.4 install] -->|first key over local admin| C + E[Fresh 0.4 joiner] -->|approved capsule, pinned when CLI-driven| C + B -.->|Operator mutation| R1[Refused, details.capability] + E -.->|no capsule or no approval| R2[Refused, stays Uninitialized] +``` + +**Awaiting activation** is the state a 0.3 record comes up in on its first 0.4 start once F7 lands. Workloads keep running. The Machine keeps peering with the Machines its record already held, but it trusts none of them to admit a join or receive the Sealing Key. Reads and `RequestMachineUpgrade` are served. Every other mutating Operator route is refused, with `next` naming the command or dashboard step that activates. Activation moves the record to activated in one write, and nothing moves it back. + +### The stable-promise argument + +The promise covers what a daemon carries or speaks. 0.4 keeps every part of it except one route semantic. + +| Promise | Under 0.4 | +| --- | --- | +| A daemon upgrades across 0.x without re-enrolling | Kept. A 0.3 Machine upgrades in place and comes up awaiting activation. | +| Replicated rows, local records, enrollment and the release source stay readable | Kept. Every new field is optional, and `device_enforcement` is read once to decide awaiting or activated, then ignored. | +| Rule 4: changed semantics get a new `.vN` route, and the old row serves until a major | **Broken, narrowly.** A mutating Operator route without customer authority is refused on every 0.4 daemon. | +| Rule 7: every refusal names `details.capability` | Kept, and checked by F1's behaviour test for every `Signed` route | +| A breaking line protects older Clusters from its own side | Kept. 0.4 Cloud drives a 0.3 Machine only to upgrade it. | + +The text F7 adds to `core/DESIGN.md`, under "Stable promise": + +> **The 0.4 authority exception.** From 0.4, a Machine refuses a route that changes it when the request carries no customer authority, and names the route's capability. This is the only semantic 0.x has withdrawn. It is not a precedent. The next withdrawal waits for a major. + +Additive activation by capability name was weighed and rejected (Appendix B). + +### What remains + +Named once in each PR body, then left alone. + +- Cloud chooses when each Machine upgrades, in what order, and whether at all. It can keep using an older channel statement on a Machine that never saw the newer one, until that Machine sees it. The person approved that channel earlier. +- `Bound` routes stay open by design. Cloud can rename a Server, change its labels and endpoints, set resource limits low enough to starve a Service, prune unused images, choose network paths, and set where crash events go. Each is a mislabel, a delay or a drop, or data Cloud already reads. +- `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are DNS and Cloudflare authority Cloud already holds. Thread X owns them. +- A Server added from Cloud's install line ran Cloud's script as root before it joined (decision 3). A Cluster founded from that line trusts the first key that line installs (Appendix A gap 6). +- A teardown a person approved runs in the order and at the time Cloud picks. + +## Program checklist + +The program runs the way `c-plan.md` "Program checklist" says, from arming and the audit tick to owners, PR mechanics, verdict and merge. These differences apply. + +- [ ] Dependency graph. F1 branches from `main` now. F2 after F1, operator decision 1, Appendix A gaps 1 and 2, and O1 confirmed by the root before merge. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gap 7. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, operator decisions 2 and 3, and gap 6. +- [ ] Hotspots. `rpc_catalog.rs` gains one row in F2, F3, F5 and F6 each. `machine_api/local.rs`, `restart_approval.rs` and `dashboard/src/server/schema.ts` are shared with C, D and E. Each F PR adds one row or one `admit_*` call and rebases promptly. +- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs`, `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. +- [ ] Bets every F PR names. The stable promise, with the argument above, and from F7 the exception. Bet 8, evidence over claims, because the dashboard calls a Cluster protected only from each Machine's own answer, and refusals name the capability. Bet 6, Machine-local authority, because each Machine checks the channel, the teardown and its own activation, and reads no other Machine's record. Bet 7, Cloud drives and never owns, because Cloud stores statements it cannot make. Bet 3, bounded imperative commands, because a channel statement and a teardown are evidence for one command, not desired state. +- [ ] Residuals each F PR names once. See "What remains". +- [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`, extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. +- [ ] Model checks. `verify-upgrade-attempt` runs in F2, since `ReleaseChoice::admit` sits inside `request_locked`. `verify-volume-runs` runs in F5 if `RemoveVolume`'s hand-off touches a run guard. + +## Classify every route, and refuse Cloud's Leave and Forget (F1) + +Outcome. `ployz_core::authority::coverage` holds one authorization class per catalog route, and two tests keep it honest. On a Machine with enforcement on, an Operator `Leave` or `Forget` is refused before any Container stops, naming the capability. A person's Device over a direct connection, and the local socket, still remove and forget Servers. Attack path closed. A new route can no longer reach `main` without a class. Cloud can no longer remove every Container on an enforcing Server, demote its Volumes and reset it to a fresh, unenforced daemon, either directly with `Leave` or through another Machine with `Forget`, which makes the forgotten Machine fence itself (`forgotten_fence.rs:238`). + +What users lose until F5. On an enforcing Cluster, the dashboard's **Remove server** and `ployz server rm` through Cloud are refused. The refusal says to run `ployz server rm` from an approved Device. Clusters that do not enforce are unchanged. + +**Depends on.** `main`. Nothing else. + +**Files.** + +- [ ] Create `core/crates/ployz-core/src/authority/coverage.rs` with `Authorization` (the five classes), `Statement` (`Join`, `Deploy`, `Restart`, `Proposal`, `Rule`, `Volume`, `Upgrade`, `Teardown`, `PrivateAccess`), `AUTHORIZATION` and `authorization(capability)`. Fill it from f-inventory.md, "Every route". Export it from `authority/mod.rs`. +- [ ] Edit `core/crates/ployz-core/tests/capabilities.rs` to add `every_route_has_one_authorization_class` (completeness and uniqueness), `full_class_matches_catalog_scope` (both ways) and `pending_routes_are_the_frozen_list`. +- [ ] Edit `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` to add `admit_operator_departure(capability)`, beside `admit_operator_build` (`:108`). Under enforcement it refuses with `Error::DepartureRefused { capability }`, which names the capability. +- [ ] Edit `core/crates/ployzd/src/machine_api/local.rs` so `leave` (`:917`) and `forget` (`:924`) call it when `from_operator`, before `depart` or `forget_machine` runs. +- [ ] Create `core/crates/ployzd/src/machine_api/authorization_tests.rs`. For each route the table classes `Signed`, it builds a valid request with no statement, sends it as an Operator through `MachineApi` on an enforcing Machine, and checks the refusal and that nothing changed. `sample_request` is a `match` on the route, so a `Signed` route with no sample fails the test. +- [ ] Map the refusal in `core/crates/ployz/src/handlers/server/` and the dashboard's removal error to "This server leaves only when you approve it. Run `ployz server rm` from an approved device." Run it through the `ui-copy` skill. +- [ ] Edit `docs/user/servers/manage-servers.md` ("Remove a server", "Forget a server that is gone", the enforcement section). + +**Verify, unit.** + +- [ ] `cargo nextest run -p ployz-core --test capabilities` passes. A scratch commit that adds a catalog row with no class fails `every_route_has_one_authorization_class`, naming the capability. Record the failing output in the PR body. +- [ ] `ployzd` tests. An Operator `Leave` under enforcement is refused, and the Machine is still Participating with its Containers and Volume roots unchanged. An Operator `Forget` naming another Machine is refused, and that Machine's row is still in the store. A Full caller is admitted for both, and so is an Operator with enforcement off. +- [ ] `cargo nextest run -p ployzd authorization_tests departure` passes. Every refusal names the route's capability or the statement capability its class lists. + +**Verify, live.** Eight lanes, three Machines, per the boot recipe. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off. `ployz server rm web-3` through Cloud at trunk and head. Save `f1-enforce-off.png`. Pass when both remove it. +- [ ] Lane 2. Attacker harness. Enforcement on. `Leave` to web-3 as Cloud. Save `f1-leave-refused.png`. Pass when it is refused naming `ployz.machine.leave.v1`, `docker ps` on web-3 is unchanged, and `ployz status` there says Participating. +- [ ] Lane 3. Attacker harness. `Forget` web-3 sent to web-1 as Cloud. Save `f1-forget-refused.png`. Pass when it is refused, web-3's row is in every Machine's store, and web-3's journal shows no fence. +- [ ] Lane 4. On trunk, run lane 2's request. Save `f1-trunk-leave.png`. Pass when trunk resets web-3. This records the hole the slice closes. +- [ ] Lane 5. Enforcement on. `ployz server rm web-3` from `cli --device` with a direct connection. Save `f1-device-rm.png`. Pass when web-3 leaves. +- [ ] Lane 6. Enforcement on. The dashboard's **Remove server**. Save `f1-dashboard-rm.png`. Pass when it shows the new refusal and nothing stops. +- [ ] Lane 7. Kill `ployzd` on web-3 while a refused `Leave` is in flight. Save `f1-kill.png`. Pass when the restarted daemon still refuses. +- [ ] Lane 8. Read the changed `docs/user` page against lanes 5 and 6. Save `f1-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. Time to refusal for an Operator `Leave` under enforcement. Rule: under 100 ms at the median of ten calls. No trunk equivalent. Record that. +- [ ] Metric. `cargo nextest run -p ployz-core --test capabilities` wall time, trunk and head. Rule: within 2 seconds of trunk. + +**Review gate.** None. + +## Upgrade only along the channel the customer signed (F2) + +Outcome. A person sets their Cluster's Release Channel once with `ployz server upgrade-policy set --releases stable` from a Device, or with one passkey prompt under **Upgrade settings**. Upgrades through Cloud, automatic or by hand, then run as today with no prompt. A Machine with enforcement on admits an Operator upgrade only to the release the signed channel resolves to, read by the Machine itself. Attack path closed. Cloud can no longer move a Stable Cluster onto a beta, or name an exact version off the customer's channel. + +What users lose. On an enforcing Cluster with no signed channel, upgrades through Cloud are refused until someone signs one. Cloud offers the prompt before the first upgrade. + +**Depends on.** F1. Operator decision 1. Appendix A gaps 1 and 2. O1 on the release repository, confirmed by the root before merge. + +**Files.** + +- [ ] Create `core/crates/ployz-core/src/authority/upgrade.rs` with `UpgradeChannel` (`cluster`, `channel`, `generation`), `verify` and a golden test. Generate its TypeScript in `core/crates/ployz-sdk/`. +- [ ] Edit `core/crates/ployz-core/src/rpc/upgrade.rs` so `RequestMachineUpgradeRequest` gains `channel: Option>`, optional and skipped when absent. Edit `rpc_catalog.rs` to add `UPGRADE_CHANNEL_CAPABILITY: ("ployz.machine.upgrade.channel.v1", Always)`, and add its `ABOVE_FLOOR` row. A Cloud that finds a Machine without it sends no statement, and the Machine upgrades along the channel as today. +- [ ] Edit `core/crates/ployzd/src/installer/upgrade.rs` so `ReleaseChoice` gains `Signed(MachineRelease)`. `admit` (`:160`) refuses a request whose release is another channel, or an exact version that channel does not resolve to, with `Error::OffChannel`. +- [ ] Edit `core/crates/ployzd/src/machine/local_machine/upgrade.rs` so `request_upgrade` verifies the statement for an Operator under enforcement, checks and records the generation high-water mark, and passes `Signed`. With enforcement on and no statement it refuses naming `ployz.machine.upgrade.channel.v1`. +- [ ] Edit `restart_approval.rs` to hold the generation high-water mark per Cluster, synced before the upgrade's receipt is written. +- [ ] Edit the `ployz server upgrade-policy set` handler to sign a new generation with the Device key and print the channel it approved. +- [ ] Edit `dashboard/src/modules/server-upgrade/**` to store the signed statement with the Organization's settings per Cluster, send it with every request, and ask the passkey directly when **Releases** changes. Run the strings through `ui-copy`. +- [ ] Run the `verify-upgrade-attempt` model check. +- [ ] Edit `docs/user/servers/manage-servers.md` ("Choose Stable or Beta releases", "Upgrade from the CLI"). + +**Verify, unit.** + +- [ ] `authority::upgrade` tests. A statement for another Cluster, a session-signed statement, an unknown key and a lower generation are each refused with their `AuthorityRefusal`. +- [ ] `installer/upgrade.rs` tests with a fake release source. Under `Signed(Stable)`, `beta` is refused, an exact version equal to Stable's pointer is admitted, and the next beta's exact version is refused. Existing signature and downgrade tests still pass. +- [ ] `local_machine/upgrade.rs` tests. Enforcement on and no statement is refused. A statement of generation 2 then one of generation 1 refuses the second, also after a reload. Enforcement off ignores the field. Run `cargo nextest run -p ployz-core authority::upgrade` and `-p ployzd upgrade`. +- [ ] Vitest for `server-upgrade` storing and sending the statement, and for the prompt firing only on a change. + +**Verify, live.** Eight lanes, three Machines. Lanes 3 and 4 point `PLOYZ_CHANNEL_URL` at a test channel, as `manage-servers.md` describes. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off. Upgrade one Server through Cloud at trunk and head. Save `f2-enforce-off.png`. Pass when both upgrade. +- [ ] Lane 2. Enforcement on. Sign Stable from `cli --device`, then **Upgrade** in the dashboard. Save `f2-upgrade.png`. Pass when every Server upgrades with no prompt and each journal names the channel generation. +- [ ] Lane 3. Attacker harness. Request `beta` on a Stable-signed Machine. Save `f2-beta-refused.png`. Pass when it is refused naming the channel and the version is unchanged. +- [ ] Lane 4. Attacker harness. Request the exact version the test channel's beta points at. Save `f2-exact-refused.png`. Pass when it is refused. +- [ ] Lane 5. Switch to Beta in the dashboard with one passkey prompt, then switch back to Stable. Replay the Beta statement. Save `f2-replay.png`. Pass when the replay is refused naming the generation. +- [ ] Lane 6. Turn on automatic upgrades with a signed channel, and let the 3-day hold pass on the test channel. Save `f2-automatic.png`. Pass when the rollout upgrades every Server with no prompt. +- [ ] Lane 7. Kill `ployzd` between the generation write and the receipt. Save `f2-kill.png`. Pass when a resend finishes the same attempt. +- [ ] Lane 8. Read the changed `docs/user` sections against lanes 2 and 5. Save `f2-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. Request to Accepted for an Operator upgrade, enforcement off at trunk and on at head, ten each, interleaved. Rule: within 50 ms of trunk at the median. Statement verification alone under 2 ms. + +**Review gate.** The operator reviews the **Upgrade settings** prompt and the CLI's printed channel, with lane 2 and 5 screenshots and a 30 to 60 second video. + +## Keep ingress and Private Access running under enforcement (F3) + +Outcome. Under enforcement, Cloud starts and repairs the Ingress Proxy and its doors as it does with enforcement off, and the daemon renews certificates with Cloud unreachable. Turning Private Access on takes one passkey prompt, and turning it off takes none. Attack path closed. Cloud cannot turn on a Private Access router that joins a tailnet the customer did not approve, which would reach every Service on the mesh. Ingress doors run only images the daemon pins, so serving them unsigned crosses no part of the bar. + +**Depends on.** F1. Appendix A gap 2. + +**Files.** + +- [ ] Edit `core/crates/ployzd/src/machine_api/local.rs` so `set_ingress_doors` (`:1161`) no longer calls `admit_operator_deploy` under enforcement. It checks that each door's image is the daemon's pinned image and refuses any other, naming `ployz.ingress.doors.v1`. Class `Bound`. +- [ ] Create `PrivateAccessApproval` in `core/crates/ployz-core/src/authority/` (`Workflow`, passkey-direct or Device, binding the Cluster and the digest of `sealed_auth_key`). Edit `SetPrivateAccessRequest` to carry it as an optional field. Add `PRIVATE_ACCESS_APPROVAL_CAPABILITY: ("ployz.private-access.approval.v1", Cluster)`. +- [ ] Edit `set_private_access` (`:1173`) so an Operator under enforcement needs the approval to turn on. Turning off needs none. +- [ ] Edit `dashboard/src/modules/ingress/doors.server.ts` to stop treating enforcement as a refusal. Edit the Private Access settings flow to seal the key in the browser and ask the passkey once. +- [ ] Edit `docs/user/services/private-networking.md` and the enforcement section of `manage-servers.md`. + +**Verify, unit.** + +- [ ] `ployzd` tests. `SetIngressDoors` with the pinned image is admitted for an Operator under enforcement, and one with another image is refused. `SetPrivateAccess` on with no approval, and with an approval over another ciphertext, are refused. Off is admitted. Run `cargo nextest run -p ployzd ingress_doors private_access`. +- [ ] F1's `authorization_tests` covers `SetPrivateAccess` as `Signed`. + +**Verify, live.** Seven lanes. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off. Add a Server that accepts ingress at trunk and head. Save `f3-enforce-off.png`. Pass when its Ingress Proxy serves. +- [ ] Lane 2. Enforcement on. Add the same Server. Save `f3-ingress.png`. Pass when its doors come up with no prompt. +- [ ] Lane 3. Attacker harness. `SetIngressDoors` with a door image Cloud picked. Save `f3-door-image.png`. Pass when it is refused and `docker ps` shows the pinned image only. +- [ ] Lane 4. Stop the Boat Cloud and move the clock past a certificate's renewal window on one Server. Save `f3-renewal.png`. Pass when Caddy renews and the route still serves. +- [ ] Lane 5. Enforcement on. Turn Private Access on from the dashboard. Save `f3-private-access.png`. Pass when one prompt appears and the router serves. +- [ ] Lane 6. Attacker harness. `SetPrivateAccess` on with a key sealed by the harness. Save `f3-tailnet-refused.png`. Pass when it is refused and no router Container exists. +- [ ] Lane 7. Read the changed `docs/user` pages against lanes 2 and 5. Save `f3-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. `SetIngressDoors` admission, trunk enforcement off and head enforcement on, ten each. Rule: within 20 ms at the median. + +**Review gate.** The operator reviews the Private Access prompt with lane 5's screenshot. + +## Sign the dashboard deploys B5 left (F4) + +Outcome. **Redeploy**, rollback, **Retry**, a Sync's deploy and a new branch's first deploy build their deploy approval in the browser, the way B5's **Deploy** does, under the same session. A Git-sourced rollback uses D6's `BuildApproval`. Attack path closed. None on the Machine, which already refuses these unsigned. What closes is the reason to keep enforcement off. Every dashboard deploy now works on an enforcing Cluster. + +**Depends on.** F1. D6 merged. + +**Files.** + +- [ ] Edit `dashboard/src/modules/config-store/**` so each of the five actions calls B5's approval helper with the plan it shows. Add the approval field to the Store's Retry. +- [ ] Edit `dashboard/src/modules/service-act/**` where a rollback starts. +- [ ] Edit `docs/user/deploy/deployments.md` and `docs/user/deploy/history-and-rollback.md` to remove "still unsigned". + +**Verify, unit.** + +- [ ] Vitest. Each action's body equals the Rust golden digest for the same plan. A plan for another Environment is refused before signing. Run `pnpm exec vitest run src/modules/config-store src/modules/approval-session`, then `pnpm typecheck` and `pnpm lint`. + +**Verify, live.** Seven lanes, `verify-dashboard` with `SERVERS=1`, enforcement on. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off. Each of the five actions at trunk and head. Save `f4-enforce-off.png`. Pass when all deploy. +- [ ] Lanes 2 to 6. One lane per action, enforcement on. Save `f4-.png`. Pass when it deploys inside the session with no extra prompt, and the Server logs the passkey. +- [ ] Lane 7. Attacker harness. Rewrite the plan a Redeploy reads, as B5's lane 6 did. Save `f4-tampered.png`. Pass when the Server refuses and the page shows why. + +**Verify, perf.** + +- [ ] Metric. Press to Deployment page for Redeploy, enforcement off at trunk and on at head, ten each, interleaved. Rule: within 200 ms, B5's rule. + +**Review gate.** The operator reviews the five actions with screenshots and a video. + +## Tear down a Namespace or a Server under approval (F5) + +Outcome. Deleting an Environment, closing a preview and removing a Server keep their data-loss confirmations, then ask for one passkey prompt in the dashboard, or none from a Device's CLI. Each Machine removes only what the signed teardown names. A closed preview's Namespace is torn down under its own preview rule. Attack path closed. Cloud cannot remove a Namespace's Containers or a Server on its own word. F1's blanket refusal of `Leave` and `Forget` becomes a check. + +**Depends on.** F1. Appendix A gaps 3, 4 and 5. E6 merged. + +**Files.** + +- [ ] Create `core/crates/ployz-core/src/authority/teardown.rs` with `Teardown` (`Namespace` and `Server` kinds, from "Design"), `verify` and golden bytes. Add `TEARDOWN_CAPABILITY: ("ployz.teardown.v1", Always)`. +- [ ] Edit `deploy_approval.rs` so an `Out` or `Remove` step for a Container in a Namespace is admitted under a Namespace teardown that names this Machine. +- [ ] Edit `machine_api/local.rs` so `leave` and `forget` admit an Operator under a Server teardown that names this Machine and the forgotten one. `RemoveVolume` goes through E6's Volume check (gap 5). +- [ ] Edit `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`, `:139`) and `core/crates/ployz/src/handlers/server/` to sign after the confirmation and carry the statement. +- [ ] Edit `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*` to send it. The preview close path sends the preview rule (gap 4). +- [ ] Edit `docs/user/servers/manage-servers.md` and the Environment deletion page. + +**Verify, unit.** + +- [ ] `authority::teardown` refuses another Namespace, another Machine, a session signer and an expired statement. +- [ ] `ployzd` tests. A `Remove` in another Namespace under a teardown is refused. A `Leave` under a teardown naming another Machine is refused. Run `cargo nextest run -p ployz-core authority::teardown` and `-p ployzd teardown`. +- [ ] CLI tests. The loss the person confirmed equals the loss in the signed statement. + +**Verify, live.** Eight lanes, three Machines, enforcement on. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off. Delete an Environment and remove a Server. Save `f5-enforce-off.png`. Pass when both behave as at trunk. +- [ ] Lane 2. Delete an Environment from the dashboard. Save `f5-env-delete.png`. Pass when the confirmation shows first, one prompt second, and every Machine logs the teardown. +- [ ] Lane 3. Remove web-3 from `cli --device`. Save `f5-server-rm.png`. Pass when it leaves. +- [ ] Lane 4. Attacker harness. A Namespace teardown replayed against another Namespace. Save `f5-other-ns.png`. Pass when every `Remove` is refused. +- [ ] Lane 5. Attacker harness. `Leave` to web-2 under web-3's Server teardown. Save `f5-other-server.png`. Pass when web-2 refuses. +- [ ] Lane 6. Close a trusted preview's pull request (needs D7). Save `f5-preview-close.png`. Pass when its derived Namespace is torn down with no prompt. +- [ ] Lane 7. Kill `ployzd` mid-teardown. Save `f5-kill.png`. Pass when the resend finishes it under the same statement. +- [ ] Lane 8. Read the changed `docs/user` pages against lanes 2 and 3. Save `f5-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. Environment deletion, trunk enforcement off and head enforcement on, five each, excluding prompt time. Rule: within 1.2 times. + +**Review gate.** The operator reviews the teardown confirmation and prompt with screenshots and a video. + +## Turn enforcement on with the 0.4 upgrade, once per Cluster (F6, checkpoint C4) + +Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI, then approves one activation for the Cluster, from a Device or with one passkey prompt. Activation trusts only Servers that answered over a path Cloud cannot rewrite, refuses any Server that cannot enforce, and has no off. The dashboard calls a Cluster protected only when every Server reports activated. Attack path closed. Cloud cannot swap a Server's key into the starting set (#3945). A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. + +**Depends on.** F2 to F5. D4 and E8 merged. Appendix A gap 7. + +**Files.** + +- [ ] Edit `rpc_catalog.rs` to add `ENFORCED_CAPABILITY: ("ployz.machine.enforced.v1", Always)`, advertised only by a daemon that gates every `Signed` route and publishes joiners only after confirmation. +- [ ] Edit `core/crates/ployz/src/handlers/device.rs` so `enforce` gathers each Server's member over each Server's direct, pinned connection, and refuses a Server without `ployz.machine.enforced.v1`, naming it. Delete `--off` (`:91`). +- [ ] Edit `restart_approval.rs` so `set_device_enforcement` (`:39`) refuses `enforced: false` once activated, naming the capability. +- [ ] Edit `dashboard/src/modules/server-upgrade/**` so automatic upgrades never cross from 0.3 to 0.4, and the 0.4 upgrade is a guided flow that ends in activation. Edit `dashboard/src/modules/machines/server-policy*` to show protection only from each Machine's reported state. +- [ ] Edit `docs/user/servers/manage-servers.md` and write the 0.4 upgrade guide under `docs/user/servers/`. + +**Verify, unit.** + +- [ ] `device_tests.rs`. A Server whose answer differs from its direct connection's key is refused before signing. A Server without the capability is named and left out. `--off` is gone from help. +- [ ] `ployzd` tests. `enforced: false` after activation is refused, also after a reload. + +**Verify, live.** Ten lanes, three Machines, starting from a Cluster on the latest 0.3 release. + +- [ ] Lane 1. Regression lane against trunk. Enforcement off on 0.4 trunk. Deploy, restart and upgrade. Save `f6-trunk.png`. Pass when all work. +- [ ] Lane 2. Upgrade the 0.3 Cluster to the head build from the dashboard, then activate with one passkey prompt. Save `f6-activate.png`. Pass when every Server reports activated and the dashboard shows it. +- [ ] Lane 3. The C4 run. On the activated Cluster, run every customer workflow on the program plan's C4 list. That is dashboard deploy, CLI deploy, start, stop, restart, settings change, GitHub push deploy, force push deploy, preview with inherited secret, Volume copy, Move, restore, join, Device add, upgrade. Save `f6-c4-.png`. Pass when each works. +- [ ] Lane 4. Attacker harness. Send every unsigned mutating route from f-inventory.md. Save `f6-unsigned.png`. Pass when each is refused naming its capability. +- [ ] Lane 5. Attacker harness. Rewrite one Server's answer to the starting-set request. Save `f6-tofu.png`. Pass when activation refuses before signing. +- [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. +- [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. +- [ ] Lane 8. Automatic upgrades on, a 0.4 release on the test channel. Save `f6-no-auto-cross.png`. Pass when no 0.3 Server moves to 0.4 on its own. +- [ ] Lane 9. Let maintenance run for a certificate renewal cycle with Cloud stopped. Save `f6-maintenance.png`. Pass when routes serve and Mirrors resume syncing once Cloud returns. +- [ ] Lane 10. Read the upgrade guide against lanes 2 and 6. Save `f6-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. Activation of three Servers, from command to every Server reporting activated. Rule: under 10 s. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. + +**Review gate.** The operator reviews the guided upgrade and activation with screenshots and a video before merge. + +## Remove the switch (F7, checkpoint C5) + +Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carries no approval and stays Uninitialized. A join driven from a person's CLI is pinned to the Cluster's authority, so a forged capsule is refused too. A new Cluster enforces from its first Server, which waits for its first key over local administration. A 0.3 record comes up awaiting activation. Cloud drives a 0.3 Machine only to upgrade it. `core/DESIGN.md` carries the 0.4 exception. Attack path closed. Cloud cannot bring a fresh Server up under Devices it chose by withholding the capsule (A1's accepted residual, #4025). No unsigned mutating path is left in daemon or Cloud. + +**Depends on.** F6. Thread R's C4 findings fixed or accepted. Operator decisions 2 and 3. Appendix A gap 6. + +**Files.** + +- [ ] Edit `core/crates/ployzd/src/machine/store.rs`. In `member` (`:472`), delete the `else` branch that returns `(devices, false)` (`:551` to `:559`). A registration without an approved capsule refuses with `JoinRefused(Unsigned { purpose: Join })` whatever the record says. `Registered.devices` stays an optional field the daemon ignores. In `founder` (`:434`), a new Cluster's record is activated with no trusted keys and waits for its first key (gap 6). +- [ ] Edit `store.rs` so `member` verifies the approval against the pin the enroll request carries, when it carries one. Add the optional pin field to `EnrollAs::Member` (gap 6). +- [ ] Delete every `!device_enforcement()` branch listed in f-inventory.md, "Where the switch lives". Keep `device_enforcement` in `record_wire.rs` as a read-only field that decides awaiting or activated on first start. +- [ ] Delete `Authorization::Pending` from `authority/coverage.rs`. Every route that still had it must have a class. +- [ ] Edit `core/crates/ployz/src/handlers/server/add.rs` and `helpers.rs` so a CLI-driven join passes the pin over SSH. +- [ ] Delete Cloud's unsigned dispatch paths in `dashboard/src/modules/{service-act,volume-run,config-store,approval-session}/**`. A Machine without `ployz.machine.enforced.v1` gets only `RequestMachineUpgrade` and reads, and the dashboard says "This server runs Ployz 0.3. Upgrade it to deploy." +- [ ] Edit `core/DESIGN.md` "Stable promise" with the text in "Design". +- [ ] Edit `docs/user/servers/add-a-server.md` to remove the paragraphs on withheld capsules and bootstrap defaults, and `manage-servers.md` to remove "turn enforcement off". + +**Verify, unit.** + +- [ ] `ployzd` `store.rs` test `a_fresh_daemon_refuses_a_join_without_an_approval`. A fresh store, a member registration with Cloud's Devices and no capsule. `plan_enrollment` returns `JoinRefused(Unsigned { purpose: Join })`, the record is still Uninitialized, and no management slot exists for those Devices. +- [ ] Same file, `a_fresh_daemon_refuses_a_capsule_with_no_approval`. A capsule that opens but carries no signed approval is refused the same way. +- [ ] Same file, `a_pinned_join_refuses_a_forged_approval`. An approval signed by a key outside the pin, sealed under another Sealing Key, is refused. +- [ ] `register_tests`. Every test that relied on enforcement off is either deleted or rewritten to an activated record. `rg 'device_enforcement\(\)' core/crates` returns only the first-start read. +- [ ] `ployz/tests/cloud_init`. A Cloud that strips the capsule from `/api/enroll` makes the join command exit non-zero with the refusal, and the daemon stays Uninitialized. +- [ ] `capabilities.rs` builds with no `Pending` variant. +- [ ] Vitest. Cloud sends nothing but upgrades and reads to a Machine without `ployz.machine.enforced.v1`. + +**Verify, live.** Ten lanes. This is the C5 rehearsal. + +- [ ] Lane 1. Regression lane against trunk. A trunk join with an approval. Save `f7-trunk-join.png`. Pass when it joins. +- [ ] Lane 2. Attacker harness. A Cloud token join on the head build with the capsule stripped. Save `f7-stripped.png`. Pass when the joiner refuses naming the join capability, stays Uninitialized, and holds no management slot. On trunk the same join enrolls with enforcement off. Record both. +- [ ] Lane 3. Attacker harness. A Cloud token join with a capsule Cloud forged under its own Sealing Key and Device. Save `f7-forged-install-line.png`. Record the result as decision 3's residual. +- [ ] Lane 4. Attacker harness. `ployz server add root@host` from `cli --device`, with the Register response rewritten to a forged capsule. Save `f7-forged-pinned.png`. Pass when the joiner refuses on the pin. +- [ ] Lane 5. A fresh install from the dashboard's line. Save `f7-founder.png`. Pass when the first Server waits for its first key, and a deploy works once it is installed. +- [ ] Lane 6. The C5 rehearsal. Build a Cluster on the latest 0.3 release, upgrade it to the 0.4 candidate, and activate. Save `f7-c5-activate.png`. Pass when every Server is activated. +- [ ] Lane 7. On that Cluster, run lane 3 of F6. Save `f7-c5-.png`. Pass when each workflow works. +- [ ] Lane 8. Attacker harness. Every unsigned route from f-inventory.md. Save `f7-c5-unsigned.png`. Pass when each is refused naming its capability. +- [ ] Lane 9. Leave one Server on 0.3 under 0.4 Cloud. Save `f7-old-machine.png`. Pass when the dashboard offers only an upgrade, and the upgrade works. +- [ ] Lane 10. Read every changed `docs/user` page against lanes 2, 5 and 6. Save `f7-docs.png`. Pass when every sentence matches. + +**Verify, perf.** + +- [ ] Metric. A Cloud token join, end to end, at F6's head and at F7's head, five each, interleaved. Rule: within 1.1 times. Time to refusal for a stripped capsule. Rule: under 1 s. + +**Review gate.** The operator reviews the `DESIGN.md` text, the founder's first-key flow and the docs before merge. + +## Appendix A. Contract gaps + +1. **`UpgradeChannel` and its record (thread A).** F2 adds a `Rule` statement and a per-Cluster generation high-water mark in the consumption log. Like D4's generation record, A agrees to the record kind. +2. **Passkey-direct signing in the dashboard (thread B).** F2's channel, F3's Private Access approval, F5's teardown and F6's activation each need a passkey assertion outside a session. It is the same helper as thread D's gap 2 and thread E's gap 6. Confirm it with the B owner. +3. **Teardown steps under a statement with no Services (thread C).** F5 admits `Out` and `Remove` for every Container of a Namespace under a teardown, not a deploy approval. C's `deploy::verify` owns step admission for a Namespace (C's gap 2), so C's owner reviews where the teardown check sits. +4. **Preview close under the preview rule (thread D).** F5 tears down a closed preview's derived Namespace under D7's preview rule with no new prompt. D's owner confirms the rule may permit that, or D7 adds it. +5. **`RemoveVolume` goes to E6 (thread E, E's gap 8).** F decides that E6 owns it. A Volume's removal names a loss, as E6's other approvals do, and E6 already owns `data_loss.rs` and `VolumeApproval`. E6 adds a `Remove` workflow. A Deploy with `--accept-volume-loss` signs it beside the deploy approval, and F5's Namespace teardown names the Volumes it loses through the same check. F's table holds `RemoveVolume` as `Pending("E6")` until then. The E owner agrees, or F5 builds it instead. +6. **First authority and the join pin (thread A).** F7 needs two things from A1's open follow-up. A founder enrolled with enforcement as the default must take its first key over local administration, for dashboard-only customers too, which is A1's open "first Device for dashboard-only customers" decision. And `JoinApproval` must verify against a pin the enroll request carries. A owns both shapes. F7 blocks on them. +7. **The starting set over a verified path (thread A, #3945).** F6 gathers each Server's member over its direct connection instead of through Cloud. A reviews, since the starting set is A's statement. + +E's gap 7 needs nothing from F. F's maintenance list maps onto E's slices. Automatic sync runs under the Mirror's standing approval (E3), the orphan sweep under the approval that orphaned the slot (E6), and "Volume recovery" is a Release under the Move's own approval (E5). F's coverage table records those classes, and F6's lane 9 checks they still run. + +## Appendix B. Alternatives rejected + +- **An authorization column in `rpc_catalog!`.** Every macro consumer (the tonic build, the request enum, the extractors) would have to match a column none of them reads. A table keyed by the generated constants, with a completeness test, gives the same guarantee and touches one module. +- **Additive activation by capability name, with no exception.** Clusters that never activate stay exposed for the rest of 0.x. Daemon and Cloud keep both dispatch paths until 1.0. "Enforcement is the default" would hold only for new Clusters. +- **Per-upgrade approval.** It ends automatic upgrades on enforcing Clusters, and buys nothing a standing channel does not. Cloud still cannot leave the channel, and Ployz's own signature still decides what a release contains. +- **The Machine checking a release's age.** Cloud's 3-day hold is scheduling. Moving it into the Machine would need a clock and a date source Cloud does not control, to close a delay. +- **Making `Leave` and `Forget` `Full` in the catalog.** That would break Cloud's removal on Clusters that do not enforce. F1 refuses them only under enforcement, as D1 and E1 did. +- **A pin in Cloud's install line.** Cloud writes the line and its script runs as root first. Decision 3. + +## Appendix C. What was checked, and what was not + +Read on `main` at `02aaa926b5`, not run. + +- **Read.** No `admit_operator_*` call in `leave` or `forget` (`ployzd/src/machine_api/local.rs:917`, `:924`). `depart` removes every managed Container, demotes or seals storage, and commits a reset (`local_machine.rs:1159` to `:1260`). `complete_reset` deletes the data directory, the enforcement flag with it (`machine/store.rs:940`). `Forget` deletes the other Machine's row (`local_machine.rs:1107`), and that Machine fences itself (`forgotten_fence.rs:238`). +- **Read.** `member` takes Cloud's `devices` with enforcement off when no approved capsule opens (`machine/store.rs:551` to `:559`). With a capsule, it checks the approval against the Devices the approval itself lists unless the record already enforces (`:504`). +- **Read.** An Operator upgrade may name `stable`, `beta`, or an exact version equal to either channel's pointer (`installer/upgrade.rs:159` to `:181`). The Organization's channel lives only in Cloud's database (`dashboard/src/modules/server-upgrade/server-upgrade.server.ts:112`). +- **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. +- **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. + +Unproven until the slice that proves it: + +- The attacker harness can send `Leave` and `Forget` as Cloud on a Boat Cluster. F1 lane 4 at trunk. +- Every `Signed` route's refusal already names `details.capability`. F1's behaviour test. A route whose refusal does not is fixed in F1. +- `verify-cluster` builds a Cluster on the latest 0.3 release. F6 lane 2. +- A daemon's first start after F7 can tell a 0.3 record from a fresh one with only the fields 0.3 wrote. F7's unit tests. + +## Appendix D. Handoffs + +- **Thread A.** Gaps 1, 6 and 7. +- **Thread B.** Gap 2. +- **Thread C.** Gap 3. F4 reuses C's `DeployApproval` unchanged. +- **Thread D.** Gap 4. F4 uses D6's `BuildApproval` for a Git rollback. F7 removes the old dispatched-workflow build path, per d-plan's handoff. +- **Thread E.** Gap 5. F7 removes the unsigned Volume paths, per e-plan's handoff. +- **Thread X.** `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are classed `Bound` with X's report as the reason. +- **Thread R.** F6 lanes 4 and 5 and F7 lanes 2 to 4 are the starting set. They cover every unsigned route, a rewritten starting set, a stripped capsule, a forged capsule on an install-line join, and a forged capsule on a pinned join. +- **Operator, outside code.** Y's O1 on the release repository before F2 merges. From 3c69f0280964d09560033cc131a2cc320b974ca7 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:14:05 +0000 Subject: [PATCH 2/9] Record F's operator decisions, pin the starting set's keys, and drive the live matrix from F1's cases The operator accepted the standing channel, the narrow 0.4 exception and pinned CLI joins. F6 now takes the starting set's keys from each Machine's own peer snapshot and, on the CLI, from pins Cloud doesn't write, with a test that substitutes both identity and answer. The live lanes run F1's per-branch cases, so Bound rows are expected to succeed. --- docs/agents/plans/security-0.4/f-inventory.md | 42 ++++++++- docs/agents/plans/security-0.4/f-plan.md | 94 ++++++++++++------- 2 files changed, 103 insertions(+), 33 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-inventory.md b/docs/agents/plans/security-0.4/f-inventory.md index 6eec13501f..0e9bfc5990 100644 --- a/docs/agents/plans/security-0.4/f-inventory.md +++ b/docs/agents/plans/security-0.4/f-inventory.md @@ -6,7 +6,7 @@ A route **crosses the bar** when a compromised Cloud can use it, under enforceme ## Every route -`Signed(…)` lists the statements that admit an Operator's call. `Signed()` with nothing listed means no statement admits it yet, and only a Full caller can call it. The class column is the class F1 writes. A different class later names the slice that moves it. +`Signed(…)` lists the statements that admit an Operator's call. `Signed()` with nothing listed means no statement admits it yet, and only a Full caller can call it. The class column is the class F1 writes. A different class later names the slice that moves it. A `Bound` row succeeds unsigned by design, so "Live matrix" below, not this table alone, is what the live lanes expect. ### Reads @@ -58,6 +58,46 @@ Refused at dispatch for any caller but the local socket or a Device's own connec | `SetIngressDoors` | `ployz.ingress.doors.v1` | Refused (`machine_api/local.rs:1167`) | `Signed()` | `Bound("daemon-pinned images; tunnel token is thread X's")` at F3 | | `SetPrivateAccess` | `ployz.private-access.v1` | Refused (`machine_api/local.rs:1179`) | `Signed()` | `Signed(PrivateAccess)` at F3 | +## Live matrix + +The attacker harness never assumes an unsigned call is refused. It runs F1's `CASES` (f-plan.md, "Authorization classes"), which give each branch its own expected answer. "Refused" means refused naming the capability with nothing changed. "Bounded" means served with the check holding. "Served" means served. The column is the answer after F7. A `Pending` row is Served until the slice named in "Every route". + +Routes that carry several mutations need one case per branch: + +| Route | Branch | Expected from Cloud, unsigned, on an enforcing Machine | +| --- | --- | --- | +| `Enroll` | Member with no capsule | Refused (F7) | +| `Enroll` | Member with an approved capsule | Served | +| `Enroll` | Founder | Refused until its first key arrives over local administration (F7, gap 6) | +| `EnsureContainer` | Create | Refused. Needs a deploy approval. | +| `EnsureContainer` | Start, stop, restart | Refused. Needs a restart, deploy, proposal or rule approval. | +| `EnsureContainer` | `Out`, `Remove` | Refused. Needs a deploy approval, or a teardown from F5. | +| `RequestMachineUpgrade` | The release the signed channel resolves to | Served (F2) | +| `RequestMachineUpgrade` | Another channel, an exact version off the channel, or no statement | Refused (F2) | +| `RequestMachineUpgrade` | A downgrade | Refused | +| `SetIngressDoors` | The daemon's pinned door image | Bounded (F3) | +| `SetIngressDoors` | Any other image | Refused (F3) | +| `SetPrivateAccess` | On | Refused (F3) | +| `SetPrivateAccess` | Off | Served (F3) | +| `GenerateSealingKey` | The Cluster publishes no key | Bounded. The key stays on the Machine. | +| `GenerateSealingKey` | The Cluster already publishes one | Refused | +| `InstallSealingKey` | Sealed by an accepted Machine's key | Bounded | +| `InstallSealingKey` | Sealed by any other key | Refused | +| `SetManagementClient` | From Cloud's own connection | Bounded. Device slots are untouched. | +| `SetManagementClient` | From the mesh, or naming a Device slot | Refused | +| `SetVolumeCopy` | A Writer want with a start | Refused (E1) | +| `SetVolumeCopy` | Pull, cut, promote, forget | Refused from E6 | +| `SetVolumeCopy` | `Floor`, `Snapshot` | Served. A residual, as "Every route" says. | +| `SetVolumeSize` | Grow, or a shrink | Refused from E7. A shrink below what the copy holds is refused at any slice. | +| `MintBuildGrant` | With a rule and its proof | Served | +| `MintBuildGrant` | Without | Refused | +| `Leave`, `Forget` | No statement | Refused (F1) | +| `Leave`, `Forget` | A teardown naming another Machine | Refused (F5) | +| `PullImage`, `PullImageFromMachine`, `EnsureImageIngest` | Any image | Bounded. A following unsigned `EnsureContainer` create with that image is refused. | +| `SetDeviceEnforcement` and every other `Full` row | From the mesh | Refused at dispatch | + +Every other `Bound` row has one Served case. Every other `Signed` row has one Refused case. + ## Automatic effects Each runs with no person present. "Class" is the route class it rides, or "local" for work the daemon does without an RPC. diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index ba94113730..e0914b06aa 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -15,16 +15,16 @@ Seven PRs, in dependency order: | F3 | Ingress doors start, and Private Access turns on, under enforcement | Maintenance that today only works with enforcement off. A Cloud-chosen tailnet reaching the mesh. | | F4 | Redeploy, rollback, Retry, a Sync's deploy and a new branch's deploy are signed in the browser | The unsigned dashboard deploys B5 left. Each is refused under enforcement today. | | F5 | Deleting an Environment, closing a preview and removing a Server run under a signed teardown | Cloud tearing down a Namespace or a Server on its own word | -| F6 | The 0.4 upgrade ends in one activation per Cluster, which can't be turned off | The trust-on-first-use starting set (#3945). A Server too old to enforce in an enforcing Cluster (#3980, and A1's older-admitter window). | -| F7 | The switch is gone. A fresh daemon refuses an unapproved join, and a new Cluster enforces from its first Server. | The capsule-less join (A1's residual). Every unsigned path in daemon and Cloud. | +| F6 | The 0.4 upgrade ends in one activation per Cluster, which can't be turned off. Each Machine checks the starting set against its own record of its peers. | The trust-on-first-use starting set (#3945), including a key Cloud substitutes behind a Server's connection. A Server too old to enforce in an enforcing Cluster (#3980, and A1's older-admitter window). | +| F7 | The switch is gone. A fresh daemon refuses an unapproved join, and a new Cluster enforces from its first Server. | The capsule-less join (A1's residual). Every unsigned path in daemon and Cloud that crosses the bar. | ## Operator decisions -Three. None blocks F1. Each has one recommendation. +Three. The operator accepted all three recommendations on 2026-11-10, so none blocks a slice. -1. **Per-upgrade approval or a standing channel.** Automatic upgrades run with no person present (`docs/user/servers/manage-servers.md`, "Turn on automatic upgrades"). An approval per upgrade would end them on enforcing Clusters. *Recommendation.* A standing channel. A person signs "this Cluster follows Stable" (or Beta) once, from a Device or with one passkey prompt, and signs again only to switch. Each Machine admits an upgrade from Cloud only to the release that channel resolves to, which the Machine reads itself. Cloud still picks when to upgrade and in what order, which is a delay or reorder under the bar. -2. **How 0.4 squares with the stable promise.** The promise says a changed route semantic gets a new `.vN` route, and the old row serves until a major. Refusing an unsigned `EnsureContainer` changes the semantic of a row that 0.3 serves. *Recommendation.* A narrow, dated 0.4 exception, written into `core/DESIGN.md` by F7, with the text in "The stable-promise argument" below. Every 0.4 daemon refuses mutating Operator routes that carry no customer authority, naming `details.capability` as rule 7 already requires. A Machine still upgrades in place without re-enrolling, and every row and record stays readable. The alternative is additive activation by capability name. It needs no exception, but it keeps the unsigned paths in daemon and Cloud until 1.0 for every Cluster that never activates. That is the permanent mixed-version dispatcher the decision record rules out, and it leaves those Clusters fully exposed. -3. **What "refuses an unapproved join" can promise.** Deleting the capsule-less path stops a joiner from enrolling with no approval. A fresh daemon still has nothing to check an approval against, so Cloud could forge one under its own Sealing Key and Device. *Recommendation.* Joins driven by a person's CLI (`ployz server add` from an approved Device) pass the joiner a pin of the Cluster's authority over SSH, and the joiner refuses any approval that does not chain to it. A join from Cloud's install line runs Cloud's script as root before anything else, so a pin there proves nothing. F7 names that residual once instead of building a check that can't hold. +1. **Per-upgrade approval or a standing channel.** Automatic upgrades run with no person present (`docs/user/servers/manage-servers.md`, "Turn on automatic upgrades"). An approval per upgrade would end them on enforcing Clusters. *Decided, as recommended.* A standing channel. A person signs "this Cluster follows Stable" (or Beta) once, from a Device or with one passkey prompt, and signs again only to switch. Each Machine admits an upgrade from Cloud only to the release that channel resolves to, which the Machine reads itself. Cloud still picks when to upgrade and in what order, which is a delay or reorder under the bar. +2. **How 0.4 squares with the stable promise.** The promise says a changed route semantic gets a new `.vN` route, and the old row serves until a major. Refusing an unsigned `EnsureContainer` changes the semantic of a row that 0.3 serves. *Decided, as recommended.* A narrow, dated 0.4 exception, written into `core/DESIGN.md` by F7, with the text in "The stable-promise argument" below. Every 0.4 daemon refuses mutating Operator routes that carry no customer authority, naming `details.capability` as rule 7 already requires. A Machine still upgrades in place without re-enrolling, and every row and record stays readable. The alternative is additive activation by capability name. It needs no exception, but it keeps the unsigned paths in daemon and Cloud until 1.0 for every Cluster that never activates. That is the permanent mixed-version dispatcher the decision record rules out, and it leaves those Clusters fully exposed. +3. **What "refuses an unapproved join" can promise.** Deleting the capsule-less path stops a joiner from enrolling with no approval. A fresh daemon still has nothing to check an approval against, so Cloud could forge one under its own Sealing Key and Device. *Decided, as recommended.* Pin CLI-driven joins, and name install-line joins as a residual. Joins driven by a person's CLI (`ployz server add` from an approved Device) pass the joiner a pin of the Cluster's authority over SSH, and the joiner refuses any approval that does not chain to it. A join from Cloud's install line runs Cloud's script as root before anything else, so a pin there proves nothing. F7 names that residual once instead of building a check that can't hold. ## How to read this @@ -52,6 +52,16 @@ Each catalog route gets exactly one class, in a table in `ployz_core::authority: Two tests hold the table to the code. A static test in `ployz-core` checks that every catalog route appears exactly once, that `Full` matches the catalog's scope column both ways, and that the `Pending` list equals a frozen list. A behaviour test in `ployzd` drives each `Signed` route through the real `MachineApi` as an Operator on an enforcing Machine, with a valid request that carries no statement. It checks the refusal names `details.capability` and that nothing changed. A `Signed` route without a sample request fails the test. +**The live matrix comes from the table.** "Unsigned" is not the same as "refused". A `Bound` route succeeds unsigned by design, and one route can carry several mutations with different answers. So F1 also writes `CASES` beside the table, one case per branch: the capability, the branch (for example `EnsureContainer` create, or `EnsureContainer` restart), the request the harness sends, and what an enforcing Machine must do with it from Cloud. + +| Expected | Meaning | Used for | +| --- | --- | --- | +| `Refused` | Refused, naming the capability. Nothing changed. | Every branch of a `Signed` row, and a `Full` row sent from the mesh | +| `Bounded(check)` | Served, and the named check holds. For example, a door with another image is refused while the pinned image is served. | A `Bound` row whose reason has a check | +| `Served` | Served | A `Bound` row whose reason is a residual, and a `Pending` row until its slice | + +The static test checks that every `Signed` and `Bound` row has at least one case, and that each multi-branch route in f-inventory.md "Live matrix" has a case for every branch listed there. The behaviour test runs every case, not only the `Signed` rows. `cargo run -p ployz-core --example coverage_cases` prints the cases as JSON. The attacker harness reads that output at the head under test and keeps no list of its own, so a live lane follows the table as later slices move rows. + ### The upgrade channel One statement, `UpgradeChannel`, in `ployz_core::authority::upgrade`, signed under `Purpose::Rule`, so a session grant cannot sign it. It binds the Cluster's Sealing public key, the channel (`stable` or `beta`), and a generation. It names no Machines and no release, and it does not expire. @@ -64,7 +74,7 @@ Cloud stores the signed statement with the Organization's upgrade settings and a The Machine resolves the channel itself, as it does today (`installer/release.rs:126`), so the pointer read and the minisign check stay where they are. The downgrade refusal (`installer/upgrade.rs:130`) is unchanged. A Full caller keeps `AnyRelease`. -This only promises something if Ployz Cloud's operator cannot produce a signed release. That is Y's action O1 on the release repository. It means a `v*` tag ruleset, required reviewers on the `release` environment, and no write-capable App installation on the organization. The root confirms O1 is in place before F2 merges, and F2's PR body says so. +This only promises something if Ployz Cloud's operator cannot produce a signed release. F2 merges only after the release-signing protections in the operator's checklist are applied. ### Teardowns @@ -80,7 +90,15 @@ The existing data-loss confirmation stays first, and what the person confirmed i ### Activation -Activation is today's `ployz device enforce` with three changes. The starting set is gathered over a path Cloud cannot rewrite, closing #3945. The set refuses any Machine that does not advertise `ployz.machine.enforced.v1`. And there is no `--off`. +Activation is today's `ployz device enforce` with three changes. Every key in the starting set must match a source Cloud doesn't write, closing #3945. The set refuses any Machine that does not advertise `ployz.machine.enforced.v1`. And there is no `--off`. + +**Where the starting set's keys come from.** Today `enforce` reads each member through the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). Cloud picks both the key a connection dials and the Machine that answers. It can put its own key behind a Server's name, answer `Inspect` with a matching member, and advertise `ployz.machine.enforced.v1`. Nothing Cloud returns can pin a key, so F6 takes keys from two sources Cloud doesn't write. The dashboard path can use only the first. + +1. **Each Machine's own peer snapshot, on every path.** The first time a daemon that advertises `ployz.machine.enforced.v1` starts, it writes the ID and WireGuard public key of every Member its record holds to a local snapshot. After that, the snapshot grows only by a join whose signed `JoinApproval` this Machine verified, so Cloud can't add to it. `SetDeviceEnforcement` refuses a starting set that names a Member outside the snapshot, or a known ID under another key, and names that Member. Each Machine checks only its own snapshot (bet 6). If Cloud pads or swaps a Member in the set, every real Machine refuses it. If Cloud trims the set, that's a drop. +2. **The signer's own pins, on the CLI path.** The CLI signs nothing until it has pinned every Server's key from one of three places. The first is the Machines this Device approved a join for. `approve_waiting_join` already shows the joiner's key (`ployz/src/handlers/device.rs:282`), and F6 records the ID and key in a known-Machines file beside the Device key. The second is a key read over the person's own SSH from that Server's local socket. The third is a fingerprint the person confirms against `ployz status` run on that Server's console. The Server Access response then supplies only addresses. The CLI reads each member over a connection that dials the pinned key, and refuses to sign while any Server is unpinned, naming it. `--yes` doesn't confirm a fingerprint, so a non-interactive run needs a Device pin or an SSH pin. +3. **The dashboard-only path.** Cloud serves the page, so nothing the browser shows or builds is independent of Cloud. The passkey signs whatever set the page gives it, so this path rests on source 1 alone. The page lists each Server with its key fingerprint and tells the person to compare it with `ployz status` on the box. That comparison catches a swap, but nothing enforces it. + +Source 1 can't see a Machine that Cloud added to the mesh before any Server started F6's daemon, because that Machine is in every snapshot. The CLI path refuses it unless the person pins it. The dashboard path trusts it. That exposure dates from the 0.3 Cluster rather than from activation, and every Server on that path was added through Cloud's install line, which decision 3 already names. A Server that joined without approval after the snapshot is named at activation. The person removes it, or approves its join again first. `ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, and when it publishes an approved joiner only after the joiner's confirmation (#4025). Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. @@ -89,7 +107,7 @@ For a dashboard-only customer, activation is the last step of the guided 0.4 upg ```mermaid flowchart LR A[0.3 Cluster] -->|guided upgrade, one Server at a time| B[0.4 daemons, awaiting activation] - B -->|one approval: starting set from a Device or passkey| C[Activated: enforcing, no off] + B -->|one approval from a Device or passkey, checked by every Machine against its peer snapshot| C[Activated: enforcing, no off] D[Fresh 0.4 install] -->|first key over local admin| C E[Fresh 0.4 joiner] -->|approved capsule, pinned when CLI-driven| C B -.->|Operator mutation| R1[Refused, details.capability] @@ -125,17 +143,19 @@ Named once in each PR body, then left alone. - `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are DNS and Cloudflare authority Cloud already holds. Thread X owns them. - A Server added from Cloud's install line ran Cloud's script as root before it joined (decision 3). A Cluster founded from that line trusts the first key that line installs (Appendix A gap 6). - A teardown a person approved runs in the order and at the time Cloud picks. +- A dashboard-only activation trusts a Machine that Cloud added to the mesh before any Server started F6's daemon. That Machine is in every peer snapshot, and only the CLI path's pins catch it ("Activation"). +- Cloud can leave a real Server out of the starting set. That's a drop. The Server stays awaiting activation and is named. ## Program checklist The program runs the way `c-plan.md` "Program checklist" says, from arming and the audit tick to owners, PR mechanics, verdict and merge. These differences apply. -- [ ] Dependency graph. F1 branches from `main` now. F2 after F1, operator decision 1, Appendix A gaps 1 and 2, and O1 confirmed by the root before merge. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gap 7. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, operator decisions 2 and 3, and gap 6. +- [ ] Dependency graph. F1 branches from `main` now. F2 after F1 and Appendix A gaps 1 and 2. F2 merges only after the release-signing protections in the operator's checklist are applied. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gap 7. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, and gap 6. - [ ] Hotspots. `rpc_catalog.rs` gains one row in F2, F3, F5 and F6 each. `machine_api/local.rs`, `restart_approval.rs` and `dashboard/src/server/schema.ts` are shared with C, D and E. Each F PR adds one row or one `admit_*` call and rebases promptly. -- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs`, `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. +- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the peer snapshot), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. - [ ] Bets every F PR names. The stable promise, with the argument above, and from F7 the exception. Bet 8, evidence over claims, because the dashboard calls a Cluster protected only from each Machine's own answer, and refusals name the capability. Bet 6, Machine-local authority, because each Machine checks the channel, the teardown and its own activation, and reads no other Machine's record. Bet 7, Cloud drives and never owns, because Cloud stores statements it cannot make. Bet 3, bounded imperative commands, because a channel statement and a teardown are evidence for one command, not desired state. - [ ] Residuals each F PR names once. See "What remains". -- [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`, extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. +- [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`. From F1 it takes its route cases from `coverage_cases` at the head under test. It is also extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. - [ ] Model checks. `verify-upgrade-attempt` runs in F2, since `ReleaseChoice::admit` sits inside `request_locked`. `verify-volume-runs` runs in F5 if `RemoveVolume`'s hand-off touches a run guard. ## Classify every route, and refuse Cloud's Leave and Forget (F1) @@ -148,11 +168,12 @@ What users lose until F5. On an enforcing Cluster, the dashboard's **Remove serv **Files.** -- [ ] Create `core/crates/ployz-core/src/authority/coverage.rs` with `Authorization` (the five classes), `Statement` (`Join`, `Deploy`, `Restart`, `Proposal`, `Rule`, `Volume`, `Upgrade`, `Teardown`, `PrivateAccess`), `AUTHORIZATION` and `authorization(capability)`. Fill it from f-inventory.md, "Every route". Export it from `authority/mod.rs`. -- [ ] Edit `core/crates/ployz-core/tests/capabilities.rs` to add `every_route_has_one_authorization_class` (completeness and uniqueness), `full_class_matches_catalog_scope` (both ways) and `pending_routes_are_the_frozen_list`. +- [ ] Create `core/crates/ployz-core/src/authority/coverage.rs` with `Authorization` (the five classes), `Statement` (`Join`, `Deploy`, `Restart`, `Proposal`, `Rule`, `Volume`, `Upgrade`, `Teardown`, `PrivateAccess`), `AUTHORIZATION`, `authorization(capability)`, `Expected` (`Refused`, `Bounded`, `Served`) and `CASES`. Fill the table from f-inventory.md, "Every route", and the cases from "Live matrix". Export it from `authority/mod.rs`. Add `core/crates/ployz-core/examples/coverage_cases.rs`, which prints `CASES` as JSON. +- [ ] Edit `core/crates/ployz-core/tests/capabilities.rs` to add `every_route_has_one_authorization_class` (completeness and uniqueness), `full_class_matches_catalog_scope` (both ways), `pending_routes_are_the_frozen_list` and `every_signed_and_bound_route_has_its_cases`. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` to add `admit_operator_departure(capability)`, beside `admit_operator_build` (`:108`). Under enforcement it refuses with `Error::DepartureRefused { capability }`, which names the capability. - [ ] Edit `core/crates/ployzd/src/machine_api/local.rs` so `leave` (`:917`) and `forget` (`:924`) call it when `from_operator`, before `depart` or `forget_machine` runs. -- [ ] Create `core/crates/ployzd/src/machine_api/authorization_tests.rs`. For each route the table classes `Signed`, it builds a valid request with no statement, sends it as an Operator through `MachineApi` on an enforcing Machine, and checks the refusal and that nothing changed. `sample_request` is a `match` on the route, so a `Signed` route with no sample fails the test. +- [ ] Create `core/crates/ployzd/src/machine_api/authorization_tests.rs`. For each entry in `CASES`, it builds the case's request with no statement, sends it as an Operator through `MachineApi` on an enforcing Machine, and checks the expected answer: refused with nothing changed, served with the check holding, or served. `sample_request` is a `match` on the case, so a case with no sample fails the test. +- [ ] Extend C1's attacker harness in `.audit/c-attacker/` to read the `coverage_cases` output instead of a hand list. - [ ] Map the refusal in `core/crates/ployz/src/handlers/server/` and the dashboard's removal error to "This server leaves only when you approve it. Run `ployz server rm` from an approved device." Run it through the `ui-copy` skill. - [ ] Edit `docs/user/servers/manage-servers.md` ("Remove a server", "Forget a server that is gone", the enforcement section). @@ -160,7 +181,7 @@ What users lose until F5. On an enforcing Cluster, the dashboard's **Remove serv - [ ] `cargo nextest run -p ployz-core --test capabilities` passes. A scratch commit that adds a catalog row with no class fails `every_route_has_one_authorization_class`, naming the capability. Record the failing output in the PR body. - [ ] `ployzd` tests. An Operator `Leave` under enforcement is refused, and the Machine is still Participating with its Containers and Volume roots unchanged. An Operator `Forget` naming another Machine is refused, and that Machine's row is still in the store. A Full caller is admitted for both, and so is an Operator with enforcement off. -- [ ] `cargo nextest run -p ployzd authorization_tests departure` passes. Every refusal names the route's capability or the statement capability its class lists. +- [ ] `cargo nextest run -p ployzd authorization_tests departure` passes. Every refusal names the route's capability or the statement capability its class lists. Every `Bounded` case's check holds, and every `Served` case is served. **Verify, live.** Eight lanes, three Machines, per the boot recipe. @@ -186,7 +207,9 @@ Outcome. A person sets their Cluster's Release Channel once with `ployz server u What users lose. On an enforcing Cluster with no signed channel, upgrades through Cloud are refused until someone signs one. Cloud offers the prompt before the first upgrade. -**Depends on.** F1. Operator decision 1. Appendix A gaps 1 and 2. O1 on the release repository, confirmed by the root before merge. +**Depends on.** F1. Appendix A gaps 1 and 2. + +**Gate.** F2 merges only after the release-signing protections in the operator's checklist are applied. **Files.** @@ -327,30 +350,36 @@ Outcome. Deleting an Environment, closing a preview and removing a Server keep t ## Turn enforcement on with the 0.4 upgrade, once per Cluster (F6, checkpoint C4) -Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI, then approves one activation for the Cluster, from a Device or with one passkey prompt. Activation trusts only Servers that answered over a path Cloud cannot rewrite, refuses any Server that cannot enforce, and has no off. The dashboard calls a Cluster protected only when every Server reports activated. Attack path closed. Cloud cannot swap a Server's key into the starting set (#3945). A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. +Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI, then approves one activation for the Cluster, from a Device or with one passkey prompt. Each Machine accepts the starting set only when every Member in it is in that Machine's own peer snapshot. The CLI also signs only keys it pinned itself. Activation refuses any Server that cannot enforce, and has no off. The dashboard calls a Cluster protected only when every Server reports activated. Attack path closed. Cloud cannot put its own key into the starting set (#3945). That covers a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match, and a Member added to the set the passkey signs in the dashboard. A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. -**Depends on.** F2 to F5. D4 and E8 merged. Appendix A gap 7. +**Depends on.** F2 to F5. D4 and E8 merged. Appendix A gap 7 (the snapshot and the approval that travels with a Member row). **Files.** - [ ] Edit `rpc_catalog.rs` to add `ENFORCED_CAPABILITY: ("ployz.machine.enforced.v1", Always)`, advertised only by a daemon that gates every `Signed` route and publishes joiners only after confirmation. -- [ ] Edit `core/crates/ployz/src/handlers/device.rs` so `enforce` gathers each Server's member over each Server's direct, pinned connection, and refuses a Server without `ployz.machine.enforced.v1`, naming it. Delete `--off` (`:91`). +- [ ] Create the peer snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it on the first start that advertises `ployz.machine.enforced.v1`, and extend it only on a verified `JoinApproval`. Edit `restart_approval.rs` so `set_device_enforcement` refuses a starting set that names a Member outside it, naming the Member. +- [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the third pin source and the dashboard page both compare against it. +- [ ] Add a known-Machines file beside the Device key, next to the `key` module in `core/crates/ployz/src/handlers/device.rs`. `approve_waiting_join` (`device.rs:282`) records the approved joiner's ID and key there. +- [ ] Edit `core/crates/ployz/src/handlers/device.rs` so `enforce` pins every Server's key first, from the known-Machines file, from an SSH read of the local socket, or from a fingerprint the person confirms. Then it reads each member over a connection that dials the pinned key and uses the Server Access response only for addresses. It refuses an unpinned Server, a member whose key is not the pin, and a Server without `ployz.machine.enforced.v1`, naming each, before it signs. Delete `--off` (`:91`). - [ ] Edit `restart_approval.rs` so `set_device_enforcement` (`:39`) refuses `enforced: false` once activated, naming the capability. -- [ ] Edit `dashboard/src/modules/server-upgrade/**` so automatic upgrades never cross from 0.3 to 0.4, and the 0.4 upgrade is a guided flow that ends in activation. Edit `dashboard/src/modules/machines/server-policy*` to show protection only from each Machine's reported state. +- [ ] Edit `dashboard/src/modules/server-upgrade/**` so automatic upgrades never cross from 0.3 to 0.4, and the 0.4 upgrade is a guided flow that ends in activation. Edit `dashboard/src/modules/machines/server-policy*` to show protection only from each Machine's reported state. The activation page lists every Server with its key fingerprint and how to compare it on the box. - [ ] Edit `docs/user/servers/manage-servers.md` and write the 0.4 upgrade guide under `docs/user/servers/`. **Verify, unit.** -- [ ] `device_tests.rs`. A Server whose answer differs from its direct connection's key is refused before signing. A Server without the capability is named and left out. `--off` is gone from help. -- [ ] `ployzd` tests. `enforced: false` after activation is refused, also after a reload. +- [ ] `device_tests.rs` `enforce_refuses_a_substituted_identity_and_answer`. A fake Server Access response swaps web-2's management identity for a harness key. A harness daemon behind it answers `Inspect` with a member under that key and advertises `ployz.machine.enforced.v1`. Pass when `enforce` exits before signing, naming web-2, with no signature made and no `SetDeviceEnforcement` sent. The same run with web-2's real key in the known-Machines file reaches signing, and its set holds the real key. +- [ ] `device_tests.rs`. An unpinned Server under `--yes` is refused, naming it. A Server without the capability is named and left out. `--off` is gone from help. +- [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A validly signed set that adds a Member absent from the snapshot is refused, naming it. So is one that names a snapshot ID under another key. The record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. +- [ ] `ployzd` tests. `enforced: false` after activation is refused, also after a reload. The snapshot survives a restart and is never rewritten from the store. -**Verify, live.** Ten lanes, three Machines, starting from a Cluster on the latest 0.3 release. +**Verify, live.** Eleven lanes, three Machines, starting from a Cluster on the latest 0.3 release. - [ ] Lane 1. Regression lane against trunk. Enforcement off on 0.4 trunk. Deploy, restart and upgrade. Save `f6-trunk.png`. Pass when all work. - [ ] Lane 2. Upgrade the 0.3 Cluster to the head build from the dashboard, then activate with one passkey prompt. Save `f6-activate.png`. Pass when every Server reports activated and the dashboard shows it. - [ ] Lane 3. The C4 run. On the activated Cluster, run every customer workflow on the program plan's C4 list. That is dashboard deploy, CLI deploy, start, stop, restart, settings change, GitHub push deploy, force push deploy, preview with inherited secret, Volume copy, Move, restore, join, Device add, upgrade. Save `f6-c4-.png`. Pass when each works. -- [ ] Lane 4. Attacker harness. Send every unsigned mutating route from f-inventory.md. Save `f6-unsigned.png`. Pass when each is refused naming its capability. -- [ ] Lane 5. Attacker harness. Rewrite one Server's answer to the starting-set request. Save `f6-tofu.png`. Pass when activation refuses before signing. +- [ ] Lane 4. Attacker harness. Run every case `coverage_cases` prints at this head. Save `f6-matrix.png` and the harness's per-case log. Pass when each `Refused` case is refused naming its capability with nothing changed, each `Bounded` case is served and its check holds, and each `Served` case is served. A `Pending` row left at this point fails the lane. +- [ ] Lane 5. Attacker harness, CLI path. Rewrite the Server Access response so web-2's connection dials a harness Machine, and have it answer `Inspect` with a matching member that advertises `ployz.machine.enforced.v1`. Save `f6-tofu-cli.png`. Pass when `ployz device enforce` refuses before signing, naming web-2, and no Server's record changes. +- [ ] Lane 5b. Attacker harness, dashboard path. As Cloud, give the activation page a starting set with a harness Member added, then one with web-2's ID under the harness key, and tap the passkey for each. Save `f6-tofu-dashboard.png`. Pass when every real Server refuses both, naming the Member, and none reports activated. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. - [ ] Lane 8. Automatic upgrades on, a 0.4 release on the test channel. Save `f6-no-auto-cross.png`. Pass when no 0.3 Server moves to 0.4 on its own. @@ -365,9 +394,9 @@ Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI, ## Remove the switch (F7, checkpoint C5) -Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carries no approval and stays Uninitialized. A join driven from a person's CLI is pinned to the Cluster's authority, so a forged capsule is refused too. A new Cluster enforces from its first Server, which waits for its first key over local administration. A 0.3 record comes up awaiting activation. Cloud drives a 0.3 Machine only to upgrade it. `core/DESIGN.md` carries the 0.4 exception. Attack path closed. Cloud cannot bring a fresh Server up under Devices it chose by withholding the capsule (A1's accepted residual, #4025). No unsigned mutating path is left in daemon or Cloud. +Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carries no approval and stays Uninitialized. A join driven from a person's CLI is pinned to the Cluster's authority, so a forged capsule is refused too. A new Cluster enforces from its first Server, which waits for its first key over local administration. A 0.3 record comes up awaiting activation. Cloud drives a 0.3 Machine only to upgrade it. `core/DESIGN.md` carries the 0.4 exception. Attack path closed. Cloud cannot bring a fresh Server up under Devices it chose by withholding the capsule (A1's accepted residual, #4025). No unsigned path that crosses the bar is left in daemon or Cloud. The `Bound` routes stay unsigned by design. -**Depends on.** F6. Thread R's C4 findings fixed or accepted. Operator decisions 2 and 3. Appendix A gap 6. +**Depends on.** F6. Thread R's C4 findings fixed or accepted. Appendix A gap 6. **Files.** @@ -399,7 +428,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - [ ] Lane 5. A fresh install from the dashboard's line. Save `f7-founder.png`. Pass when the first Server waits for its first key, and a deploy works once it is installed. - [ ] Lane 6. The C5 rehearsal. Build a Cluster on the latest 0.3 release, upgrade it to the 0.4 candidate, and activate. Save `f7-c5-activate.png`. Pass when every Server is activated. - [ ] Lane 7. On that Cluster, run lane 3 of F6. Save `f7-c5-.png`. Pass when each workflow works. -- [ ] Lane 8. Attacker harness. Every unsigned route from f-inventory.md. Save `f7-c5-unsigned.png`. Pass when each is refused naming its capability. +- [ ] Lane 8. Attacker harness. Run every case `coverage_cases` prints at this head, as F6 lane 4 does. Save `f7-c5-matrix.png` and the per-case log. Pass when every case gets its expected answer. With `Pending` deleted, every case is `Refused`, `Bounded` or `Served`. - [ ] Lane 9. Leave one Server on 0.3 under 0.4 Cloud. Save `f7-old-machine.png`. Pass when the dashboard offers only an upgrade, and the upgrade works. - [ ] Lane 10. Read every changed `docs/user` page against lanes 2, 5 and 6. Save `f7-docs.png`. Pass when every sentence matches. @@ -417,7 +446,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie 4. **Preview close under the preview rule (thread D).** F5 tears down a closed preview's derived Namespace under D7's preview rule with no new prompt. D's owner confirms the rule may permit that, or D7 adds it. 5. **`RemoveVolume` goes to E6 (thread E, E's gap 8).** F decides that E6 owns it. A Volume's removal names a loss, as E6's other approvals do, and E6 already owns `data_loss.rs` and `VolumeApproval`. E6 adds a `Remove` workflow. A Deploy with `--accept-volume-loss` signs it beside the deploy approval, and F5's Namespace teardown names the Volumes it loses through the same check. F's table holds `RemoveVolume` as `Pending("E6")` until then. The E owner agrees, or F5 builds it instead. 6. **First authority and the join pin (thread A).** F7 needs two things from A1's open follow-up. A founder enrolled with enforcement as the default must take its first key over local administration, for dashboard-only customers too, which is A1's open "first Device for dashboard-only customers" decision. And `JoinApproval` must verify against a pin the enroll request carries. A owns both shapes. F7 blocks on them. -7. **The starting set over a verified path (thread A, #3945).** F6 gathers each Server's member over its direct connection instead of through Cloud. A reviews, since the starting set is A's statement. +7. **Where the starting set's keys come from (thread A, #3945).** F6 needs three things from A, since the starting set and `JoinApproval` are A's statements. The first is a local peer snapshot record, written once and extended only by a verified `JoinApproval`, which `SetDeviceEnforcement` checks the set against. The second is a signed `JoinApproval` that travels with a new Member's replicated row, so every Machine, not only the admitter, can verify it before extending its snapshot. The third is the Device's known-Machines file, recorded at `ApproveJoin`. *Recommendation.* Accept the dashboard-only limit in "Activation", for the reason given there. Requiring a direct-administration step for dashboard-only activation is the alternative, and A decides it together with gap 6's first key. E's gap 7 needs nothing from F. F's maintenance list maps onto E's slices. Automatic sync runs under the Mirror's standing approval (E3), the orphan sweep under the approval that orphaned the slot (E6), and "Volume recovery" is a Release under the Move's own approval (E5). F's coverage table records those classes, and F6's lane 9 checks they still run. @@ -439,6 +468,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** An Operator upgrade may name `stable`, `beta`, or an exact version equal to either channel's pointer (`installer/upgrade.rs:159` to `:181`). The Organization's channel lives only in Cloud's database (`dashboard/src/modules/server-upgrade/server-upgrade.server.ts:112`). - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. +- **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: @@ -455,5 +485,5 @@ Unproven until the slice that proves it: - **Thread D.** Gap 4. F4 uses D6's `BuildApproval` for a Git rollback. F7 removes the old dispatched-workflow build path, per d-plan's handoff. - **Thread E.** Gap 5. F7 removes the unsigned Volume paths, per e-plan's handoff. - **Thread X.** `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are classed `Bound` with X's report as the reason. -- **Thread R.** F6 lanes 4 and 5 and F7 lanes 2 to 4 are the starting set. They cover every unsigned route, a rewritten starting set, a stripped capsule, a forged capsule on an install-line join, and a forged capsule on a pinned join. -- **Operator, outside code.** Y's O1 on the release repository before F2 merges. +- **Thread R.** F6 lanes 4 and 5 and F7 lanes 2 to 4 are the starting set. They cover every case in the live matrix, a starting set with a substituted Server, one padded through the dashboard, a stripped capsule, a forged capsule on an install-line join, and a forged capsule on a pinned join. +- **Operator, outside code.** F2 merges only after the release-signing protections in the operator's checklist are applied. From f5e826d2706212fb79a97e00109212a40520db90 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:24:31 +0000 Subject: [PATCH 3/9] Trust nothing learned with enforcement off at activation, and rotate the Sealing Key The trusted snapshot holds only Members and keys backed by evidence the Machine checked while it enforced. Legacy Members need a person's pin on the CLI path, delivered over the local socket, and the dashboard offers activation only when nothing is legacy. Activation rotates the Sealing Key to the approved set. Adds the planted-before-F6 cases for both paths. --- docs/agents/plans/security-0.4/f-plan.md | 142 +++++++++++++++++------ 1 file changed, 109 insertions(+), 33 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index e0914b06aa..3e03be1d63 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -15,7 +15,7 @@ Seven PRs, in dependency order: | F3 | Ingress doors start, and Private Access turns on, under enforcement | Maintenance that today only works with enforcement off. A Cloud-chosen tailnet reaching the mesh. | | F4 | Redeploy, rollback, Retry, a Sync's deploy and a new branch's deploy are signed in the browser | The unsigned dashboard deploys B5 left. Each is refused under enforcement today. | | F5 | Deleting an Environment, closing a preview and removing a Server run under a signed teardown | Cloud tearing down a Namespace or a Server on its own word | -| F6 | The 0.4 upgrade ends in one activation per Cluster, which can't be turned off. Each Machine checks the starting set against its own record of its peers. | The trust-on-first-use starting set (#3945), including a key Cloud substitutes behind a Server's connection. A Server too old to enforce in an enforcing Cluster (#3980, and A1's older-admitter window). | +| F6 | The 0.4 upgrade ends in one activation per Cluster, which can't be turned off. Nothing learned with enforcement off is trusted unless a person verified it, and the Sealing Key rotates to the approved set. | The trust-on-first-use starting set (#3945): a Member or key Cloud planted with enforcement off, or a key it swaps behind a Server's connection. A Server too old to enforce in an enforcing Cluster (#3980, and A1's older-admitter window). | | F7 | The switch is gone. A fresh daemon refuses an unapproved join, and a new Cluster enforces from its first Server. | The capsule-less join (A1's residual). Every unsigned path in daemon and Cloud that crosses the bar. | ## Operator decisions @@ -90,24 +90,47 @@ The existing data-loss confirmation stays first, and what the person confirmed i ### Activation -Activation is today's `ployz device enforce` with three changes. Every key in the starting set must match a source Cloud doesn't write, closing #3945. The set refuses any Machine that does not advertise `ployz.machine.enforced.v1`. And there is no `--off`. +Activation is today's `ployz device enforce` with four changes. It promotes nothing a Machine learned while enforcement was off, unless a person verified it. It refuses any Machine that does not advertise `ployz.machine.enforced.v1`. It rotates the Sealing Key to the approved set. And there is no `--off`. -**Where the starting set's keys come from.** Today `enforce` reads each member through the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). Cloud picks both the key a connection dials and the Machine that answers. It can put its own key behind a Server's name, answer `Inspect` with a matching member, and advertise `ployz.machine.enforced.v1`. Nothing Cloud returns can pin a key, so F6 takes keys from two sources Cloud doesn't write. The dashboard path can use only the first. +**Why nothing learned with enforcement off counts.** Today `enforce` reads each member through the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). Cloud picks both the key a connection dials and the Machine that answers. Worse, with enforcement off, Cloud can enroll a Machine of its own, and the daemon gives any non-local mesh caller Full scope (`ployzd/src/machine_api/mod.rs:185`). So that Machine can also add Device keys to every real Machine's record. A Member or a trusted key that a Machine learned while not enforcing is therefore Cloud's word, and so is a Member from a starting set gathered today (#3945). Activation treats each of these as **legacy**. -1. **Each Machine's own peer snapshot, on every path.** The first time a daemon that advertises `ployz.machine.enforced.v1` starts, it writes the ID and WireGuard public key of every Member its record holds to a local snapshot. After that, the snapshot grows only by a join whose signed `JoinApproval` this Machine verified, so Cloud can't add to it. `SetDeviceEnforcement` refuses a starting set that names a Member outside the snapshot, or a known ID under another key, and names that Member. Each Machine checks only its own snapshot (bet 6). If Cloud pads or swaps a Member in the set, every real Machine refuses it. If Cloud trims the set, that's a drop. -2. **The signer's own pins, on the CLI path.** The CLI signs nothing until it has pinned every Server's key from one of three places. The first is the Machines this Device approved a join for. `approve_waiting_join` already shows the joiner's key (`ployz/src/handlers/device.rs:282`), and F6 records the ID and key in a known-Machines file beside the Device key. The second is a key read over the person's own SSH from that Server's local socket. The third is a fingerprint the person confirms against `ployz status` run on that Server's console. The Server Access response then supplies only addresses. The CLI reads each member over a connection that dials the pinned key, and refuses to sign while any Server is unpinned, naming it. `--yes` doesn't confirm a fingerprint, so a non-interactive run needs a Device pin or an SSH pin. -3. **The dashboard-only path.** Cloud serves the page, so nothing the browser shows or builds is independent of Cloud. The passkey signs whatever set the page gives it, so this path rests on source 1 alone. The page lists each Server with its key fingerprint and tells the person to compare it with `ployz status` on the box. That comparison catches a swap, but nothing enforces it. +**What counts as evidence.** A Member has evidence on a Machine when that Machine verified the Member's signed `JoinApproval` while it enforced. A trusted key has evidence when that Machine verified the statement that enrolled it while it enforced, and the chain ends in a key a person installed over local administration. Everything else is legacy. On its first start, a daemon that advertises `ployz.machine.enforced.v1` does two things. It stops giving Full scope to non-local mesh callers, so no Machine can plant a key after this point. And it writes a **trusted snapshot** that holds only itself and the Members and keys that have evidence. After that, the snapshot grows only by a join whose `JoinApproval` this Machine verified. Each Machine reads only its own snapshot (bet 6). -Source 1 can't see a Machine that Cloud added to the mesh before any Server started F6's daemon, because that Machine is in every snapshot. The CLI path refuses it unless the person pins it. The dashboard path trusts it. That exposure dates from the 0.3 Cluster rather than from activation, and every Server on that path was added through Cloud's install line, which decision 3 already names. A Server that joined without approval after the snapshot is named at activation. The person removes it, or approves its join again first. +**How each Machine checks a starting set.** `SetDeviceEnforcement` admits a starting set in two cases. One is that every Member and the signer are in this Machine's trusted snapshot. The other is that the set arrives over this Machine's local socket, from the person's own SSH or console. In that case the Machine also records the signer as installed over local administration. Anything else is refused, naming the legacy Member or key. A swapped or added Member fails on every real Machine. A Member Cloud leaves out is a drop, and that Server stays awaiting activation, named. -`ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, and when it publishes an approved joiner only after the joiner's confirmation (#4025). Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. +**The CLI path.** This is the path for any Cluster that still has a legacy Member or key, which in practice means every 0.3 Cluster. -For a dashboard-only customer, activation is the last step of the guided 0.4 upgrade. Cloud does not roll a Cluster from 0.3 to 0.4 automatically. +- The CLI signs nothing until it has pinned every Server's key itself. It takes the pin from the known-Machines file, from the person's own SSH, or from a fingerprint the person confirms. + - The known-Machines file holds the Machines this Device approved a join for. `approve_waiting_join` already shows the joiner's key (`ployz/src/handlers/device.rs:282`), and F6 records it. + - For SSH, the CLI reads the key from the Server's local socket. + - For a fingerprint, the person checks it against `ployz status` on that Server's console. +- Cloud's Server Access response supplies addresses only. The CLI reads each member over a connection that dials the pinned key. +- A legacy Member that the person doesn't pin is named and left out. `--yes` doesn't count as confirming a fingerprint. +- When the Cluster has anything legacy, the CLI delivers the signed set to each Server over SSH to its local socket. A Server it can't reach that way stays awaiting activation, named. + +**The dashboard path.** The page offers activation only when every Machine reports that its starting set needs no local socket: every Member and every key has evidence. Otherwise it offers no passkey button, and sends the person to `ployz device enforce` on a Device, naming the legacy Members and keys. Cloud serves the page, so this rule is not what holds. What holds is that the Machines refuse a set the page builds over a legacy Member, whatever the page shows. + +**Rotating the Sealing Key at activation.** Under enforcement off, `ShareSealingKey` needs no approval. Any Machine that was ever a Member may hold the Cluster's Sealing Key, including one that has since left. Activation can't revoke a copy. *Recommendation: F6 rotates the key as the last step of activation.* + +1. One activated Machine generates a new key and seals it to each Member in the approved set's WireGuard key. The Machine is chosen by the lowest ID, so every resend picks the same one. +2. The Cluster publishes the new public key. New values seal only to it. +3. Each activated Machine keeps the old key, but only to open stored values. It re-seals each one to the new key when asked, and it re-seals only to the key the activation statement names, never to one Cloud supplies. +4. On the CLI path, activation prints a new recovery code, as `ployz recovery rotate` does. The dashboard can't show a code that Cloud doesn't also see, but a dashboard activation is only offered with no legacy anywhere. It still says to run `ployz recovery rotate` from a terminal, because Cloud's backup opens only the old key. + +The rotation is keyed by the activation's operation ID, so a resend after a crash converges on the same new key (thread A contract, gap 8). The alternative is to rotate only when activation left a Member out. It was rejected because a past holder may no longer be a Member at all. + +**What activation cannot undo.** Values sealed before activation were open to any Machine that held the old key, and Cloud holds their ciphertext. Rotation protects values set after it. Before activation, a Machine Cloud enrolled could also call Full routes, `Exec` among them, on every other Machine. The 0.4 upgrade guide says both plainly. A person who doubts the Cluster's history changes their secrets at the source. Neither is a residual of F, because both happened before any 0.4 promise held. + +`ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, publishes an approved joiner only after the joiner's confirmation (#4025), and keeps the trusted snapshot above. Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. + +Cloud does not roll a Cluster from 0.3 to 0.4 automatically. The guided 0.4 upgrade ends in activation from the dashboard only when nothing is legacy. Otherwise it ends by sending the person to the CLI. ```mermaid flowchart LR A[0.3 Cluster] -->|guided upgrade, one Server at a time| B[0.4 daemons, awaiting activation] - B -->|one approval from a Device or passkey, checked by every Machine against its peer snapshot| C[Activated: enforcing, no off] + B -->|legacy Member or key: CLI pins keys, delivers over SSH| C[Activated: enforcing, Sealing Key rotated, no off] + B -->|nothing legacy: one Device or passkey approval| C + B -.->|dashboard, legacy present| R3[No button: run ployz device enforce] D[Fresh 0.4 install] -->|first key over local admin| C E[Fresh 0.4 joiner] -->|approved capsule, pinned when CLI-driven| C B -.->|Operator mutation| R1[Refused, details.capability] @@ -143,16 +166,15 @@ Named once in each PR body, then left alone. - `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are DNS and Cloudflare authority Cloud already holds. Thread X owns them. - A Server added from Cloud's install line ran Cloud's script as root before it joined (decision 3). A Cluster founded from that line trusts the first key that line installs (Appendix A gap 6). - A teardown a person approved runs in the order and at the time Cloud picks. -- A dashboard-only activation trusts a Machine that Cloud added to the mesh before any Server started F6's daemon. That Machine is in every peer snapshot, and only the CLI path's pins catch it ("Activation"). - Cloud can leave a real Server out of the starting set. That's a drop. The Server stays awaiting activation and is named. ## Program checklist The program runs the way `c-plan.md` "Program checklist" says, from arming and the audit tick to owners, PR mechanics, verdict and merge. These differences apply. -- [ ] Dependency graph. F1 branches from `main` now. F2 after F1 and Appendix A gaps 1 and 2. F2 merges only after the release-signing protections in the operator's checklist are applied. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gap 7. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, and gap 6. +- [ ] Dependency graph. F1 branches from `main` now. F2 after F1 and Appendix A gaps 1 and 2. F2 merges only after the release-signing protections in the operator's checklist are applied. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gaps 7 and 8. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, and gap 6. - [ ] Hotspots. `rpc_catalog.rs` gains one row in F2, F3, F5 and F6 each. `machine_api/local.rs`, `restart_approval.rs` and `dashboard/src/server/schema.ts` are shared with C, D and E. Each F PR adds one row or one `admit_*` call and rebases promptly. -- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the peer snapshot), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. +- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the trusted snapshot), `machine_api/mod.rs` (mesh scope), `local_machine/sealing.rs` (rotation), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. - [ ] Bets every F PR names. The stable promise, with the argument above, and from F7 the exception. Bet 8, evidence over claims, because the dashboard calls a Cluster protected only from each Machine's own answer, and refusals name the capability. Bet 6, Machine-local authority, because each Machine checks the channel, the teardown and its own activation, and reads no other Machine's record. Bet 7, Cloud drives and never owns, because Cloud stores statements it cannot make. Bet 3, bounded imperative commands, because a channel statement and a teardown are evidence for one command, not desired state. - [ ] Residuals each F PR names once. See "What remains". - [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`. From F1 it takes its route cases from `coverage_cases` at the head under test. It is also extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. @@ -350,47 +372,90 @@ Outcome. Deleting an Environment, closing a preview and removing a Server keep t ## Turn enforcement on with the 0.4 upgrade, once per Cluster (F6, checkpoint C4) -Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI, then approves one activation for the Cluster, from a Device or with one passkey prompt. Each Machine accepts the starting set only when every Member in it is in that Machine's own peer snapshot. The CLI also signs only keys it pinned itself. Activation refuses any Server that cannot enforce, and has no off. The dashboard calls a Cluster protected only when every Server reports activated. Attack path closed. Cloud cannot put its own key into the starting set (#3945). That covers a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match, and a Member added to the set the passkey signs in the dashboard. A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. +Outcome. A 0.3 customer upgrades their Servers to 0.4 from the dashboard or CLI. They then activate the Cluster once with `ployz device enforce` from a Device, which pins each Server's key and delivers the signed set over SSH. A Cluster with nothing legacy can instead activate with one passkey prompt in the dashboard. Each Machine admits only Members and keys it holds evidence for, or a set that arrives over its own local socket. Activation rotates the Sealing Key to the approved set, refuses any Server that cannot enforce, and has no off. The dashboard calls a Cluster protected only when every Server reports activated. + +Attack path closed. Cloud cannot get a Machine of its own into the activated Cluster, and cannot get its own key trusted there (#3945). That covers four cases: + +- a Member or Device key Cloud planted while enforcement was off, including one that was in place before any Server ran F6's daemon; +- a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match; +- a Member added to a set the passkey signs in the dashboard; +- a past holder of the Sealing Key opening values sealed after activation. -**Depends on.** F2 to F5. D4 and E8 merged. Appendix A gap 7 (the snapshot and the approval that travels with a Member row). +A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. + +What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Device and SSH to each Server once, to activate. Once F6's daemon starts, a Device's Full call no longer works through another Machine. It needs a direct connection, as it already does under enforcement. Activation prints a new recovery code, and the old one stops opening anything sealed after activation. + +**Depends on.** F2 to F5. D4 and E8 merged. Appendix A gaps 7 (evidence, the trusted snapshot and local-socket activation) and 8 (Sealing Key rotation). **Files.** -- [ ] Edit `rpc_catalog.rs` to add `ENFORCED_CAPABILITY: ("ployz.machine.enforced.v1", Always)`, advertised only by a daemon that gates every `Signed` route and publishes joiners only after confirmation. -- [ ] Create the peer snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it on the first start that advertises `ployz.machine.enforced.v1`, and extend it only on a verified `JoinApproval`. Edit `restart_approval.rs` so `set_device_enforcement` refuses a starting set that names a Member outside it, naming the Member. -- [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the third pin source and the dashboard page both compare against it. +- [ ] Edit `rpc_catalog.rs` to add `ENFORCED_CAPABILITY: ("ployz.machine.enforced.v1", Always)`, advertised only by a daemon that gates every `Signed` route, publishes joiners only after confirmation, and keeps the trusted snapshot. +- [ ] Edit `core/crates/ployzd/src/machine_api/mod.rs` (`:185`) so a daemon that advertises `ployz.machine.enforced.v1` never gives a non-local mesh caller Full scope, whether or not it enforces. +- [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. +- [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. +- [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. +- [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. - [ ] Add a known-Machines file beside the Device key, next to the `key` module in `core/crates/ployz/src/handlers/device.rs`. `approve_waiting_join` (`device.rs:282`) records the approved joiner's ID and key there. -- [ ] Edit `core/crates/ployz/src/handlers/device.rs` so `enforce` pins every Server's key first, from the known-Machines file, from an SSH read of the local socket, or from a fingerprint the person confirms. Then it reads each member over a connection that dials the pinned key and uses the Server Access response only for addresses. It refuses an unpinned Server, a member whose key is not the pin, and a Server without `ployz.machine.enforced.v1`, naming each, before it signs. Delete `--off` (`:91`). -- [ ] Edit `restart_approval.rs` so `set_device_enforcement` (`:39`) refuses `enforced: false` once activated, naming the capability. -- [ ] Edit `dashboard/src/modules/server-upgrade/**` so automatic upgrades never cross from 0.3 to 0.4, and the 0.4 upgrade is a guided flow that ends in activation. Edit `dashboard/src/modules/machines/server-policy*` to show protection only from each Machine's reported state. The activation page lists every Server with its key fingerprint and how to compare it on the box. -- [ ] Edit `docs/user/servers/manage-servers.md` and write the 0.4 upgrade guide under `docs/user/servers/`. +- [ ] Edit `core/crates/ployz/src/handlers/device.rs` so `enforce` works in this order. + - Pin every Server's key first, from the known-Machines file, from an SSH read of the local socket, or from a fingerprint the person confirms. + - Read each member over a connection that dials the pinned key, using the Server Access response only for addresses. + - Name and leave out each legacy Member the person doesn't pin. + - Refuse a member whose key is not the pin, and a Server without `ployz.machine.enforced.v1`, naming each, before signing. + - When anything is legacy, deliver the set to each Server over SSH to its local socket. + - Print the new recovery code. + - Delete `--off` (`:91`). +- [ ] Edit `dashboard/src/modules/server-upgrade/**`. + - Automatic upgrades never cross from 0.3 to 0.4. + - The 0.4 upgrade is a guided flow. It offers the passkey activation only when every Server reports that nothing is legacy. Otherwise it names the legacy Members and keys and gives the `ployz device enforce` command. + - It re-seals stored values through an activated Machine, and sends the person to `ployz recovery rotate`. +- [ ] Edit `dashboard/src/modules/machines/server-policy*` to show protection only from each Machine's reported state. +- [ ] Edit `docs/user/servers/manage-servers.md` and `docs/user/servers/recovery-code.md`. Write the 0.4 upgrade guide under `docs/user/servers/`, including "What activation cannot undo". **Verify, unit.** +- [ ] `ployzd` test `a_member_from_before_the_first_f6_start_is_legacy`. A record holds web-1, web-2 and a harness Member that joined with enforcement off, plus a Device key added from the mesh with enforcement off. Restart it as F6's daemon. Pass when the snapshot holds neither the harness Member nor that key. + - A set signed by the passkey and naming the harness Member, sent over a Device connection, is refused naming the Member. + - A set signed by the planted key is refused naming the key. + - The same set without the harness Member, sent over the local socket, is admitted, and its signer is recorded as installed over local administration. +- [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. +- [ ] `ployzd` test `a_mesh_caller_is_never_full_on_an_enforced_v1_daemon`. With enforcement off, a mesh `SetDevice` is refused at dispatch. +- [ ] `ployzd` tests for rotation. + - After activation, a value sealed to the new key opens on every activated Machine. + - A harness Machine holding only the old key can't open it. + - A resend with the same operation ID yields the same public key. + - A kill between generating and sharing converges on resend. + - Re-sealing refuses a target key other than the one the activation names. - [ ] `device_tests.rs` `enforce_refuses_a_substituted_identity_and_answer`. A fake Server Access response swaps web-2's management identity for a harness key. A harness daemon behind it answers `Inspect` with a member under that key and advertises `ployz.machine.enforced.v1`. Pass when `enforce` exits before signing, naming web-2, with no signature made and no `SetDeviceEnforcement` sent. The same run with web-2's real key in the known-Machines file reaches signing, and its set holds the real key. -- [ ] `device_tests.rs`. An unpinned Server under `--yes` is refused, naming it. A Server without the capability is named and left out. `--off` is gone from help. -- [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A validly signed set that adds a Member absent from the snapshot is refused, naming it. So is one that names a snapshot ID under another key. The record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. +- [ ] `device_tests.rs` `enforce_leaves_out_an_unpinned_legacy_member`. A Cluster has a legacy harness Member the person doesn't pin. Pass when it is named and absent from the signed set. When the person confirms its fingerprint, it is included. Under `--yes`, an unpinned Server is refused, naming it. A Server without the capability is named and left out. `--off` is gone from help. - [ ] `ployzd` tests. `enforced: false` after activation is refused, also after a reload. The snapshot survives a restart and is never rewritten from the store. +- [ ] Vitest for `server-upgrade`. With any Server reporting a legacy Member or key, the activation step renders no passkey button and shows `ployz device enforce`. -**Verify, live.** Eleven lanes, three Machines, starting from a Cluster on the latest 0.3 release. +**Verify, live.** Thirteen lanes, three Machines, starting from a Cluster on the latest 0.3 release. - [ ] Lane 1. Regression lane against trunk. Enforcement off on 0.4 trunk. Deploy, restart and upgrade. Save `f6-trunk.png`. Pass when all work. -- [ ] Lane 2. Upgrade the 0.3 Cluster to the head build from the dashboard, then activate with one passkey prompt. Save `f6-activate.png`. Pass when every Server reports activated and the dashboard shows it. +- [ ] Lane 2. Upgrade the 0.3 Cluster to the head build from the dashboard. Check that the dashboard sends the person to the CLI, then run `ployz device enforce` from `cli --device` with SSH. Save `f6-activate.png`. Pass when every Server reports activated, a new Sealing public key is published, a new recovery code is printed, and the dashboard shows the Cluster protected. +- [ ] Lane 2b. A Cluster built at head whose every join was approved under enforcement. Activate it from the dashboard with one passkey prompt. Save `f6-activate-dashboard.png`. Pass when every Server reports activated. - [ ] Lane 3. The C4 run. On the activated Cluster, run every customer workflow on the program plan's C4 list. That is dashboard deploy, CLI deploy, start, stop, restart, settings change, GitHub push deploy, force push deploy, preview with inherited secret, Volume copy, Move, restore, join, Device add, upgrade. Save `f6-c4-.png`. Pass when each works. - [ ] Lane 4. Attacker harness. Run every case `coverage_cases` prints at this head. Save `f6-matrix.png` and the harness's per-case log. Pass when each `Refused` case is refused naming its capability with nothing changed, each `Bounded` case is served and its check holds, and each `Served` case is served. A `Pending` row left at this point fails the lane. - [ ] Lane 5. Attacker harness, CLI path. Rewrite the Server Access response so web-2's connection dials a harness Machine, and have it answer `Inspect` with a matching member that advertises `ployz.machine.enforced.v1`. Save `f6-tofu-cli.png`. Pass when `ployz device enforce` refuses before signing, naming web-2, and no Server's record changes. -- [ ] Lane 5b. Attacker harness, dashboard path. As Cloud, give the activation page a starting set with a harness Member added, then one with web-2's ID under the harness key, and tap the passkey for each. Save `f6-tofu-dashboard.png`. Pass when every real Server refuses both, naming the Member, and none reports activated. +- [ ] Lane 5b. Attacker harness, dashboard path. On lane 2b's Cluster before activation, as Cloud, give the activation page a starting set with a harness Member added, then one with web-2's ID under the harness key, and tap the passkey for each. Save `f6-tofu-dashboard.png`. Pass when every real Server refuses both, naming the Member, and none reports activated. +- [ ] Lane 5c. Attacker harness, a Member planted before the first F6 start. While the Cluster runs 0.3 with enforcement off, enroll a harness Machine and add a harness Device key from it. Let the harness Machine receive the Sealing Key. Then upgrade every Server to head. Save `f6-planted-dashboard.png` and `f6-planted-cli.png`. Pass when all of these hold. + - The dashboard offers no passkey activation and names the harness Member and key. + - A passkey-signed set naming the harness Member, forced through Cloud, is refused by every real Server. + - A set signed by the harness key is refused by every real Server. + - `ployz device enforce` names the harness Member and leaves it out unless the person confirms its fingerprint. + - After activation, a value set afterwards does not open on the harness Machine. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. - [ ] Lane 8. Automatic upgrades on, a 0.4 release on the test channel. Save `f6-no-auto-cross.png`. Pass when no 0.3 Server moves to 0.4 on its own. - [ ] Lane 9. Let maintenance run for a certificate renewal cycle with Cloud stopped. Save `f6-maintenance.png`. Pass when routes serve and Mirrors resume syncing once Cloud returns. -- [ ] Lane 10. Read the upgrade guide against lanes 2 and 6. Save `f6-docs.png`. Pass when every sentence matches. +- [ ] Lane 10. Read the upgrade guide and `recovery-code.md` against lanes 2, 2b and 6. Save `f6-docs.png`. Pass when every sentence matches. **Verify, perf.** -- [ ] Metric. Activation of three Servers, from command to every Server reporting activated. Rule: under 10 s. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. +- [ ] Metric. Activation of three Servers over SSH, from command to every Server reporting activated, rotation included. Rule: under 15 s. Re-sealing 100 stored values. Rule: under 5 s. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. -**Review gate.** The operator reviews the guided upgrade and activation with screenshots and a video before merge. +**Review gate.** The operator reviews the guided upgrade, the CLI redirect, activation and the new recovery code with screenshots and a video before merge. ## Remove the switch (F7, checkpoint C5) @@ -446,7 +511,15 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie 4. **Preview close under the preview rule (thread D).** F5 tears down a closed preview's derived Namespace under D7's preview rule with no new prompt. D's owner confirms the rule may permit that, or D7 adds it. 5. **`RemoveVolume` goes to E6 (thread E, E's gap 8).** F decides that E6 owns it. A Volume's removal names a loss, as E6's other approvals do, and E6 already owns `data_loss.rs` and `VolumeApproval`. E6 adds a `Remove` workflow. A Deploy with `--accept-volume-loss` signs it beside the deploy approval, and F5's Namespace teardown names the Volumes it loses through the same check. F's table holds `RemoveVolume` as `Pending("E6")` until then. The E owner agrees, or F5 builds it instead. 6. **First authority and the join pin (thread A).** F7 needs two things from A1's open follow-up. A founder enrolled with enforcement as the default must take its first key over local administration, for dashboard-only customers too, which is A1's open "first Device for dashboard-only customers" decision. And `JoinApproval` must verify against a pin the enroll request carries. A owns both shapes. F7 blocks on them. -7. **Where the starting set's keys come from (thread A, #3945).** F6 needs three things from A, since the starting set and `JoinApproval` are A's statements. The first is a local peer snapshot record, written once and extended only by a verified `JoinApproval`, which `SetDeviceEnforcement` checks the set against. The second is a signed `JoinApproval` that travels with a new Member's replicated row, so every Machine, not only the admitter, can verify it before extending its snapshot. The third is the Device's known-Machines file, recorded at `ApproveJoin`. *Recommendation.* Accept the dashboard-only limit in "Activation", for the reason given there. Requiring a direct-administration step for dashboard-only activation is the alternative, and A decides it together with gap 6's first key. +7. **Evidence, the trusted snapshot and local-socket activation (thread A, #3945).** The starting set, `JoinApproval` and Device enrollment are A's statements, so F6 needs five things from A. + - The definition of evidence in "Activation". A Member's `JoinApproval`, or a key's enrolling statement, counts only when this Machine verified it while it enforced, and the chain ends in a key installed over local administration. + - The trusted snapshot record. It is written once at the first `ployz.machine.enforced.v1` start, holds only evidence-backed Members and keys, and is extended only by a verified `JoinApproval`. + - A signed `JoinApproval` that travels with a new Member's replicated row, so every Machine, not only the admitter, can verify it. + - `SetDeviceEnforcement` admitting a set over the local socket and recording its signer as installed over local administration. + - The Device's known-Machines file, recorded at `ApproveJoin`. + + A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). +8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. E's gap 7 needs nothing from F. F's maintenance list maps onto E's slices. Automatic sync runs under the Mirror's standing approval (E3), the orphan sweep under the approval that orphaned the slot (E6), and "Volume recovery" is a Release under the Move's own approval (E5). F's coverage table records those classes, and F6's lane 9 checks they still run. @@ -458,6 +531,8 @@ E's gap 7 needs nothing from F. F's maintenance list maps onto E's slices. Autom - **The Machine checking a release's age.** Cloud's 3-day hold is scheduling. Moving it into the Machine would need a clock and a date source Cloud does not control, to close a delay. - **Making `Leave` and `Forget` `Full` in the catalog.** That would break Cloud's removal on Clusters that do not enforce. F1 refuses them only under enforcement, as D1 and E1 did. - **A pin in Cloud's install line.** Cloud writes the line and its script runs as root first. Decision 3. +- **Seeding the snapshot from every Member present at the first F6 start.** Cloud can enroll its own Machine while enforcement is off, so that Machine would be in every snapshot, and a passkey-signed set would promote it. The snapshot takes only Members and keys with evidence. +- **Rotating the Sealing Key only when activation leaves a Member out.** Any Machine that was ever a Member may hold the key, including one that has already left, so the current membership says nothing about who holds it. ## Appendix C. What was checked, and what was not @@ -468,6 +543,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** An Operator upgrade may name `stable`, `beta`, or an exact version equal to either channel's pointer (`installer/upgrade.rs:159` to `:181`). The Organization's channel lives only in Cloud's database (`dashboard/src/modules/server-upgrade/server-upgrade.server.ts:112`). - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. +- **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: @@ -479,11 +555,11 @@ Unproven until the slice that proves it: ## Appendix D. Handoffs -- **Thread A.** Gaps 1, 6 and 7. +- **Thread A.** Gaps 1, 6, 7 and 8. - **Thread B.** Gap 2. - **Thread C.** Gap 3. F4 reuses C's `DeployApproval` unchanged. - **Thread D.** Gap 4. F4 uses D6's `BuildApproval` for a Git rollback. F7 removes the old dispatched-workflow build path, per d-plan's handoff. - **Thread E.** Gap 5. F7 removes the unsigned Volume paths, per e-plan's handoff. - **Thread X.** `PublishCertificateMaterial` and the tunnel token in `SetIngressDoors` are classed `Bound` with X's report as the reason. -- **Thread R.** F6 lanes 4 and 5 and F7 lanes 2 to 4 are the starting set. They cover every case in the live matrix, a starting set with a substituted Server, one padded through the dashboard, a stripped capsule, a forged capsule on an install-line join, and a forged capsule on a pinned join. +- **Thread R.** F6 lanes 4, 5, 5b and 5c and F7 lanes 2 to 4 are the starting set. They cover every case in the live matrix, a starting set with a substituted Server, one padded through the dashboard, a Member and key planted before the first F6 start, a stripped capsule, a forged capsule on an install-line join, and a forged capsule on a pinned join. - **Operator, outside code.** F2 merges only after the release-signing protections in the operator's checklist are applied. From 5798120db2b4e4a9b75d8a5ab238add7fc550aa1 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:29:10 +0000 Subject: [PATCH 4/9] Retire unverified Device keys and passkeys in the activation write Activation now removes every Device slot and trusted passkey outside the trusted snapshot in the same save that records the activated set, closes their live connections, and makes later Device checks read the snapshot. The CLI lists what it retires and how to add a key back. The unit test and lane 5c call Exec, WrapSealingKey and SetDevice with the planted key after activation. --- docs/agents/plans/security-0.4/f-plan.md | 38 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index 3e03be1d63..da2f50fe84 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -90,7 +90,7 @@ The existing data-loss confirmation stays first, and what the person confirmed i ### Activation -Activation is today's `ployz device enforce` with four changes. It promotes nothing a Machine learned while enforcement was off, unless a person verified it. It refuses any Machine that does not advertise `ployz.machine.enforced.v1`. It rotates the Sealing Key to the approved set. And there is no `--off`. +Activation is today's `ployz device enforce` with five changes. It promotes nothing a Machine learned while enforcement was off, unless a person verified it. It retires every Device and passkey key nobody verified. It refuses any Machine that does not advertise `ployz.machine.enforced.v1`. It rotates the Sealing Key to the approved set. And there is no `--off`. **Why nothing learned with enforcement off counts.** Today `enforce` reads each member through the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). Cloud picks both the key a connection dials and the Machine that answers. Worse, with enforcement off, Cloud can enroll a Machine of its own, and the daemon gives any non-local mesh caller Full scope (`ployzd/src/machine_api/mod.rs:185`). So that Machine can also add Device keys to every real Machine's record. A Member or a trusted key that a Machine learned while not enforcing is therefore Cloud's word, and so is a Member from a starting set gathered today (#3945). Activation treats each of these as **legacy**. @@ -98,6 +98,24 @@ Activation is today's `ployz device enforce` with four changes. It promotes noth **How each Machine checks a starting set.** `SetDeviceEnforcement` admits a starting set in two cases. One is that every Member and the signer are in this Machine's trusted snapshot. The other is that the set arrives over this Machine's local socket, from the person's own SSH or console. In that case the Machine also records the signer as installed over local administration. Anything else is refused, naming the legacy Member or key. A swapped or added Member fails on every real Machine. A Member Cloud leaves out is a drop, and that Server stays awaiting activation, named. +**Retiring legacy keys.** Refusing a planted key as the signer is not enough. Today `management_scope` gives Full scope to any key that holds a Device slot (`ployzd/src/machine/mod.rs:440`). And `set_device_enforcement` replaces the trusted Members but leaves the Device slots alone (`ployzd/src/machine/store.rs:902`). A planted Device key would still call `Exec`, `WrapSealingKey` or `SetDevice` directly after a legitimate activation. So activation does three things. + +- **One atomic write.** The write that records the activated set also retires every Device slot and every trusted passkey whose key isn't in the trusted snapshot. A passkey holds no slot, since it only approves, but statements verify against it. Three kinds of key survive. The first is the activation's signer. The second is a key with evidence. The third is a key the person pinned for this activation: the signed set names it, and the CLI pinned it as described below. Each retired key gets a Cleared tombstone, so a later dial is told it was cleared. Activation never splits into an activated record that still holds legacy slots. +- **Live connections close.** The daemon already ends every connection whose key no slot holds any more, with `REVOKED` (`ployzd/src/management/mod.rs:180`). Retiring the slot in that write closes a connection opened before activation, and every stream on it ends. +- **Every later Device check reads the snapshot.** From activation on, `management_scope` gives Full scope only to a slot whose key is in the trusted snapshot. That includes the Device check behind `Exec`, `WrapSealingKey`, `SetDevice`, `SetPasskey` and `RemoveTrustedKey`, and the trusted keys every signed statement verifies against. A slot outside the snapshot is answered as if absent. Later enrollments through `ployz device approve` are checked under enforcement, so they enter the snapshot with evidence. The Management Client slot Cloud holds keeps Operator scope only, as it does today. + +The CLI prints what it retired, per Server, before it asks the person to confirm the set. The text goes through the `ui-copy` skill. + +```text +These keys were added while Device enforcement was off. Turning it on removes them: + Device "ci-laptop" key 3f9a…c21e on web-1, web-2, web-3 + Passkey "Work Mac" key 77c1…0b4d on web-1, web-2, web-3 +To keep a key you recognize, check its fingerprint with `ployz device ls` on that computer and answer y. +To add one back later, run `ployz device request` on that computer, then `ployz device approve ` here. +``` + +A person keeps a key only by confirming its fingerprint against `ployz device ls` run on that computer, which F6 makes mark the computer's own key and fingerprint. For a passkey, they check it against the listing `ployz device ls` gives from an already trusted Device. `--yes` keeps none. Once it is retired, the key comes back only through `ployz device request` and `ployz device approve` from an activated Device, which is checked under enforcement and gives it evidence. On the dashboard path nothing is legacy, so nothing is retired. + **The CLI path.** This is the path for any Cluster that still has a legacy Member or key, which in practice means every 0.3 Cluster. - The CLI signs nothing until it has pinned every Server's key itself. It takes the pin from the known-Machines file, from the person's own SSH, or from a fingerprint the person confirms. @@ -379,11 +397,12 @@ Attack path closed. Cloud cannot get a Machine of its own into the activated Clu - a Member or Device key Cloud planted while enforcement was off, including one that was in place before any Server ran F6's daemon; - a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match; - a Member added to a set the passkey signs in the dashboard; +- a planted Device or passkey key calling `Exec`, `WrapSealingKey` or `SetDevice` directly after activation, including over a connection it opened before; - a past holder of the Sealing Key opening values sealed after activation. A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. -What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Device and SSH to each Server once, to activate. Once F6's daemon starts, a Device's Full call no longer works through another Machine. It needs a direct connection, as it already does under enforcement. Activation prints a new recovery code, and the old one stops opening anything sealed after activation. +What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Device and SSH to each Server once, to activate. Once F6's daemon starts, a Device's Full call no longer works through another Machine. It needs a direct connection, as it already does under enforcement. Activation prints a new recovery code, and the old one stops opening anything sealed after activation. Each Device or passkey added while enforcement was off is removed unless the person confirms its fingerprint, and comes back through `ployz device request` and `ployz device approve`. **Depends on.** F2 to F5. D4 and E8 merged. Appendix A gaps 7 (evidence, the trusted snapshot and local-socket activation) and 8 (Sealing Key rotation). @@ -393,6 +412,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Edit `core/crates/ployzd/src/machine_api/mod.rs` (`:185`) so a daemon that advertises `ployz.machine.enforced.v1` never gives a non-local mesh caller Full scope, whether or not it enforces. - [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. - [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. +- [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named as pinned in the signed set. It writes each retired Device key a Cleared tombstone in the same save that records the activated set. The existing record watch in `ployzd/src/management/mod.rs` (`revoked`, `:428`) then closes their connections with `REVOKED`. +- [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. - [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. - [ ] Add a known-Machines file beside the Device key, next to the `key` module in `core/crates/ployz/src/handlers/device.rs`. `approve_waiting_join` (`device.rs:282`) records the approved joiner's ID and key there. @@ -400,6 +421,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - Pin every Server's key first, from the known-Machines file, from an SSH read of the local socket, or from a fingerprint the person confirms. - Read each member over a connection that dials the pinned key, using the Server Access response only for addresses. - Name and leave out each legacy Member the person doesn't pin. + - Mark this computer's own key and fingerprint in `ployz device ls`. + - List every legacy Device and passkey key per Server with its fingerprint, keep only the ones the person confirms, and print the retired keys and how to add one back. - Refuse a member whose key is not the pin, and a Server without `ployz.machine.enforced.v1`, naming each, before signing. - When anything is legacy, deliver the set to each Server over SSH to its local socket. - Print the new recovery code. @@ -417,6 +440,11 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A set signed by the passkey and naming the harness Member, sent over a Device connection, is refused naming the Member. - A set signed by the planted key is refused naming the key. - The same set without the harness Member, sent over the local socket, is admitted, and its signer is recorded as installed over local administration. + - After that activation, the planted Device key's slot is gone and holds a Cleared tombstone, in the same saved record that marks the set activated. + - The planted key then calls `Exec`, `WrapSealingKey` and `SetDevice` directly, and each is refused. + - A connection the planted key opened before activation is closed with `REVOKED`, and a call already streaming on it ends. + - A legacy key named in `kept_keys` survives and keeps Full scope. + - A crash before the save leaves the record not activated with every slot in place. A crash after it leaves it activated with the slots retired. No state holds one change without the other. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. - [ ] `ployzd` test `a_mesh_caller_is_never_full_on_an_enforced_v1_daemon`. With enforcement off, a mesh `SetDevice` is refused at dispatch. - [ ] `ployzd` tests for rotation. @@ -426,6 +454,7 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A kill between generating and sharing converges on resend. - Re-sealing refuses a target key other than the one the activation names. - [ ] `device_tests.rs` `enforce_refuses_a_substituted_identity_and_answer`. A fake Server Access response swaps web-2's management identity for a harness key. A harness daemon behind it answers `Inspect` with a member under that key and advertises `ployz.machine.enforced.v1`. Pass when `enforce` exits before signing, naming web-2, with no signature made and no `SetDeviceEnforcement` sent. The same run with web-2's real key in the known-Machines file reaches signing, and its set holds the real key. +- [ ] `device_tests.rs` `enforce_lists_and_retires_legacy_keys`. With two legacy keys, the CLI prints both with fingerprints. Confirming one puts only that one in `kept_keys`. Under `--yes`, `kept_keys` is empty. The printed retirement names every retired key per Server and the re-add commands. - [ ] `device_tests.rs` `enforce_leaves_out_an_unpinned_legacy_member`. A Cluster has a legacy harness Member the person doesn't pin. Pass when it is named and absent from the signed set. When the person confirms its fingerprint, it is included. Under `--yes`, an unpinned Server is refused, naming it. A Server without the capability is named and left out. `--off` is gone from help. - [ ] `ployzd` tests. `enforced: false` after activation is refused, also after a reload. The snapshot survives a restart and is never rewritten from the store. - [ ] Vitest for `server-upgrade`. With any Server reporting a legacy Member or key, the activation step renders no passkey button and shows `ployz device enforce`. @@ -445,6 +474,9 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A set signed by the harness key is refused by every real Server. - `ployz device enforce` names the harness Member and leaves it out unless the person confirms its fingerprint. - After activation, a value set afterwards does not open on the harness Machine. + - The CLI's output lists the harness key as retired on every Server. + - Before activation, the harness Device opens a direct connection to web-1. After activation, that connection is closed with `REVOKED`. The harness Device then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. + - Re-adding a legitimate retired Device with `ployz device request` and `ployz device approve` restores its Full scope. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. - [ ] Lane 8. Automatic upgrades on, a 0.4 release on the test channel. Save `f6-no-auto-cross.png`. Pass when no 0.3 Server moves to 0.4 on its own. @@ -517,6 +549,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - A signed `JoinApproval` that travels with a new Member's replicated row, so every Machine, not only the admitter, can verify it. - `SetDeviceEnforcement` admitting a set over the local socket and recording its signer as installed over local administration. - The Device's known-Machines file, recorded at `ApproveJoin`. + - Retiring every Device slot and trusted passkey outside the snapshot, in the same write that records the activated set, with `kept_keys` on `StartingSet`. And `management_scope` and statement verification reading only snapshot keys once the record is activated. A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). 8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. @@ -544,6 +577,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. - **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. +- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change that removes a key from every slot closes its live connections with `REVOKED` (`ployzd/src/management/mod.rs:180`). - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: From 6253a95da1efb566c985028ec83d05cc7bc89ac4 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:35:50 +0000 Subject: [PATCH 5/9] Revoke a management connection when its key's scope narrows A key that holds both a Device slot and Cloud's Operator slot kept its open Exec stream after activation retired the Device slot, because revocation fired only once no slot admitted the key. The plan now has the watcher compare scope, adds the duplicate-slot case to the unit test and lane 5c, and extends thread A's gap 7. --- docs/agents/plans/security-0.4/f-plan.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index da2f50fe84..f6e09a521e 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -101,7 +101,9 @@ Activation is today's `ployz device enforce` with five changes. It promotes noth **Retiring legacy keys.** Refusing a planted key as the signer is not enough. Today `management_scope` gives Full scope to any key that holds a Device slot (`ployzd/src/machine/mod.rs:440`). And `set_device_enforcement` replaces the trusted Members but leaves the Device slots alone (`ployzd/src/machine/store.rs:902`). A planted Device key would still call `Exec`, `WrapSealingKey` or `SetDevice` directly after a legitimate activation. So activation does three things. - **One atomic write.** The write that records the activated set also retires every Device slot and every trusted passkey whose key isn't in the trusted snapshot. A passkey holds no slot, since it only approves, but statements verify against it. Three kinds of key survive. The first is the activation's signer. The second is a key with evidence. The third is a key the person pinned for this activation: the signed set names it, and the CLI pinned it as described below. Each retired key gets a Cleared tombstone, so a later dial is told it was cleared. Activation never splits into an activated record that still holds legacy slots. -- **Live connections close.** The daemon already ends every connection whose key no slot holds any more, with `REVOKED` (`ployzd/src/management/mod.rs:180`). Retiring the slot in that write closes a connection opened before activation, and every stream on it ends. +- **Live connections close when their scope drops.** Today the daemon ends a connection only once its key holds no slot at all (`revoked`, `ployzd/src/management/mod.rs:428`, through `admits_management_client`, `ployzd/src/machine/mod.rs:452`). That is not enough here. `approve_device` lets one key hold a Device slot and an Operator slot at once (`ployzd/src/machine/store.rs:791`). Cloud could use a Machine it planted to approve its own management key as a Device, and open an `Exec` shell before activation. Activation would retire the Device slot but keep the Operator slot, so the key would still be admitted. Revocation would never fire, and the shell would keep running commands with no new scope check (`ployzd/src/docker/stream.rs:95`). So F6 changes the watcher's test. Each connection records the scope `management_scope` gave its key when it was proven. The watcher fires when a published record gives that key a narrower scope, including none, not only when the key leaves every slot. The connection then closes with `REVOKED` through the existing path: every stream ends at once, `Exec` and attach among them, except the calls that can revoke their own caller. The key may dial again, and it gets only the scope it still holds, Operator in this case. + + *Why this and not removing the key from every slot.* This changes one test in a watcher that already exists and reuses its drain and close. It also covers every other way a key can lose Full scope, such as `ployz device rm` of a key that also holds an Operator slot, or a scope rule that changes later. Removing the key from every slot would also cut Cloud's own Operator connection on that Machine. It would need a new rule that one key never holds two slots, and a migration for Cloud's key, and it would still leave any other drop in scope uncovered. - **Every later Device check reads the snapshot.** From activation on, `management_scope` gives Full scope only to a slot whose key is in the trusted snapshot. That includes the Device check behind `Exec`, `WrapSealingKey`, `SetDevice`, `SetPasskey` and `RemoveTrustedKey`, and the trusted keys every signed statement verifies against. A slot outside the snapshot is answered as if absent. Later enrollments through `ployz device approve` are checked under enforcement, so they enter the snapshot with evidence. The Management Client slot Cloud holds keeps Operator scope only, as it does today. The CLI prints what it retired, per Server, before it asks the person to confirm the set. The text goes through the `ui-copy` skill. @@ -412,7 +414,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Edit `core/crates/ployzd/src/machine_api/mod.rs` (`:185`) so a daemon that advertises `ployz.machine.enforced.v1` never gives a non-local mesh caller Full scope, whether or not it enforces. - [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. - [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. -- [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named as pinned in the signed set. It writes each retired Device key a Cleared tombstone in the same save that records the activated set. The existing record watch in `ployzd/src/management/mod.rs` (`revoked`, `:428`) then closes their connections with `REVOKED`. +- [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named as pinned in the signed set. It writes each retired Device key a Cleared tombstone in the same save that records the activated set. +- [ ] Edit `core/crates/ployzd/src/management/mod.rs` so each connection keeps the scope its key had when proven, and `revoked` (`:428`) completes when the published record's `management_scope` for that key is narrower, not only when `admits_management_client` (`machine/mod.rs:452`) turns false. Each stream on a revoked connection ends with the existing drain, `Exec` and attach included. Edit `ployzd/src/docker/stream.rs` (`:95`) so an `Exec` stream ends when its connection is revoked, rather than finishing its current stdin loop. - [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. - [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. @@ -443,6 +446,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - After that activation, the planted Device key's slot is gone and holds a Cleared tombstone, in the same saved record that marks the set activated. - The planted key then calls `Exec`, `WrapSealingKey` and `SetDevice` directly, and each is refused. - A connection the planted key opened before activation is closed with `REVOKED`, and a call already streaming on it ends. + - The duplicate-slot case. The planted key also holds Cloud's Operator slot, through `approve_device` from the planted Machine. Before activation it opens an interactive `Exec` shell. Activation retires its Device slot, keeps its Operator slot, and closes the connection with `REVOKED`. The shell's stream ends, and a command written to it after the activation save never runs. A fresh dial with that key gets Operator scope, and its `Exec` is refused at dispatch. + - `ployz device rm` of a Device key that also holds an Operator slot closes that key's connection the same way. A record change that leaves a key's scope as it was closes nothing. - A legacy key named in `kept_keys` survives and keeps Full scope. - A crash before the save leaves the record not activated with every slot in place. A crash after it leaves it activated with the slots retired. No state holds one change without the other. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. @@ -475,7 +480,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - `ployz device enforce` names the harness Member and leaves it out unless the person confirms its fingerprint. - After activation, a value set afterwards does not open on the harness Machine. - The CLI's output lists the harness key as retired on every Server. - - Before activation, the harness Device opens a direct connection to web-1. After activation, that connection is closed with `REVOKED`. The harness Device then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. + - Before activation, the harness Device opens a direct connection to web-1. After activation, that connection is closed with `REVOKED`. + - The duplicate-slot case. While enforcement is off, the harness Machine approves Cloud's own management key as a Device on web-2, so that key holds both the Device slot and Cloud's Operator slot. That key opens an interactive `Exec` shell on web-2 before activation. Save `f6-dual-slot-exec.png`. Pass when, after activation, the shell ends, or its next command is refused and the command's effect is absent on web-2. Cloud's ordinary Operator work on web-2 must still succeed after it dials again. The harness Device then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. - Re-adding a legitimate retired Device with `ployz device request` and `ployz device approve` restores its Full scope. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. @@ -550,6 +556,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - `SetDeviceEnforcement` admitting a set over the local socket and recording its signer as installed over local administration. - The Device's known-Machines file, recorded at `ApproveJoin`. - Retiring every Device slot and trusted passkey outside the snapshot, in the same write that records the activated set, with `kept_keys` on `StartingSet`. And `management_scope` and statement verification reading only snapshot keys once the record is activated. + - Revoking a management connection when its key's scope narrows, not only when the key leaves every slot. One key may hold a Device slot and an Operator slot at once (`store.rs:791`), so retiring the Device slot alone must still end that key's Full connections and their long-lived streams. A agrees that `ManagementScope` is the unit revocation compares, and that a key may keep both slots. A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). 8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. @@ -577,7 +584,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. - **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. -- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change that removes a key from every slot closes its live connections with `REVOKED` (`ployzd/src/management/mod.rs:180`). +- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: From 61205e120aa3da00e024b5bca168d86a35d99d86 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:42:23 +0000 Subject: [PATCH 6/9] Check authority at every effect point under one gate, not at connection close Activation's save could be outrun by Exec input, by a mutation queued while its key was Full, and by a Build Grant minted before it. The plan now has writes that narrow authority hold an authority gate across save and publish, every scope-authorized effect recheck under it before acting, and activation end every grant. The watcher is cleanup only. --- docs/agents/plans/security-0.4/f-plan.md | 52 ++++++++++++++++++------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index f6e09a521e..57340f834e 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -101,9 +101,26 @@ Activation is today's `ployz device enforce` with five changes. It promotes noth **Retiring legacy keys.** Refusing a planted key as the signer is not enough. Today `management_scope` gives Full scope to any key that holds a Device slot (`ployzd/src/machine/mod.rs:440`). And `set_device_enforcement` replaces the trusted Members but leaves the Device slots alone (`ployzd/src/machine/store.rs:902`). A planted Device key would still call `Exec`, `WrapSealingKey` or `SetDevice` directly after a legitimate activation. So activation does three things. - **One atomic write.** The write that records the activated set also retires every Device slot and every trusted passkey whose key isn't in the trusted snapshot. A passkey holds no slot, since it only approves, but statements verify against it. Three kinds of key survive. The first is the activation's signer. The second is a key with evidence. The third is a key the person pinned for this activation: the signed set names it, and the CLI pinned it as described below. Each retired key gets a Cleared tombstone, so a later dial is told it was cleared. Activation never splits into an activated record that still holds legacy slots. -- **Live connections close when their scope drops.** Today the daemon ends a connection only once its key holds no slot at all (`revoked`, `ployzd/src/management/mod.rs:428`, through `admits_management_client`, `ployzd/src/machine/mod.rs:452`). That is not enough here. `approve_device` lets one key hold a Device slot and an Operator slot at once (`ployzd/src/machine/store.rs:791`). Cloud could use a Machine it planted to approve its own management key as a Device, and open an `Exec` shell before activation. Activation would retire the Device slot but keep the Operator slot, so the key would still be admitted. Revocation would never fire, and the shell would keep running commands with no new scope check (`ployzd/src/docker/stream.rs:95`). So F6 changes the watcher's test. Each connection records the scope `management_scope` gave its key when it was proven. The watcher fires when a published record gives that key a narrower scope, including none, not only when the key leaves every slot. The connection then closes with `REVOKED` through the existing path: every stream ends at once, `Exec` and attach among them, except the calls that can revoke their own caller. The key may dial again, and it gets only the scope it still holds, Operator in this case. - - *Why this and not removing the key from every slot.* This changes one test in a watcher that already exists and reuses its drain and close. It also covers every other way a key can lose Full scope, such as `ployz device rm` of a key that also holds an Operator slot, or a scope rule that changes later. Removing the key from every slot would also cut Cloud's own Operator connection on that Machine. It would need a new rule that one key never holds two slots, and a migration for Cloud's key, and it would still leave any other drop in scope uncovered. +- **Authority is checked where each effect happens, under the lock activation writes under.** Closing connections can't be the boundary, because it happens after the write. The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), but the revocation watcher notices only later (`ployzd/src/management/mod.rs:430`). Three kinds of Full authority survive that gap. + - **Exec input.** An `Exec` or attach stream forwards stdin to Docker with no further check (`ployzd/src/docker/stream.rs:95`). A command written after the save still runs. + - **Mutations already submitted.** A call's scope is captured into headers when it arrives (`ployzd/src/machine_api/mod.rs:263` to `:303`). `EnsureContainer` skips approval for a captured Full scope (`ployzd/src/machine_api/local.rs:565`). Admission then checks only that the key is still accepted (`require_management_access`, `ployzd/src/machine/local_machine.rs:396`), and the admitted work is spawned apart from its RPC (`:438` to `:454`). An unsigned create submitted while the key was Full waits behind activation, then runs under the Operator slot that survives. + - **Build Grants.** A grant records only `Minter::Full`, not which key minted it (`ployzd/src/management/build_grant.rs:74` to `:92`). Enforcement refuses only Operator-minted grants (`:364`). The grant ALPN skips connection revocation (`ployzd/src/management/mod.rs:259`). Its holder reads opened values from `GET /ployz/sealed` (`build_grant.rs:449`). A Device planted before activation could mint a grant for a key Cloud holds and read sealed values afterwards. + + F6 closes all three with one mechanism, an **authority gate** the record owner holds. + - **The write.** The gate is an `RwLock` around an authority epoch. Any record write that can narrow a key's authority takes the gate's write half for its save and publish together, and bumps the epoch. That covers activation, retiring or removing a Device slot or passkey, and `RemoveTrustedKey`. Activation also takes Machine admission exclusively first (`admission_lock`), so all admitted work finishes before the save and no queued work starts during it. + - **The effect points.** Each effect authorized by a key's scope records the key, the scope it was authorized under, and the epoch when it was authorized. Before it acts, it takes the gate's read half. If the epoch moved, it rechecks that the current record still gives the key that scope, and only then acts, still holding the read half. The effect points are: + - each `Exec` or attach stdin write and output frame; + - each chunk on the port-forward tunnel (`ployzd/src/management/tunnel.rs:49` checks scope only at open) and on Volume copy and transfer data paths; + - mutation admission, where `require_management_access` becomes "the key still holds the scope this call was authorized under", checked after the call takes its admission turn; + - every Build Grant admission and request, `/ployz/sealed` and push included. + + A failed check ends the stream or refuses the call, naming `ployz.machine.enforced.v1`. The common case is one atomic load of the epoch. A read half is held only for one write or one admission, so activation waits no longer than one chunk. + - **Build Grants.** A grant records the key that minted it and the epoch. Activation ends every live grant inside the gated write. Later, a grant whose minting key no longer holds the scope it minted under is refused at its next request. + - **The watcher.** It stays, as cleanup. It closes connections whose scope narrowed, so they stop holding resources. It is not the boundary. + + *Why one gate and not three patches.* The bar is crossed at the moment of an effect, not at a connection. One rule ("check the current authority, under the lock the narrowing write takes, before acting") covers stdin, queued mutations and grants. It also covers any later effect point and any later write that narrows authority, with no list of special cases to keep in step. A simpler fix was weighed and rejected: closing every connection synchronously inside the activation write. It still leaves spawned admitted work and grants, which don't ride a management connection, so it needs the per-effect check anyway. + + Commands a shell started before activation keep running, as "What activation cannot undo" says. After the save, such a shell can't receive input or send output. - **Every later Device check reads the snapshot.** From activation on, `management_scope` gives Full scope only to a slot whose key is in the trusted snapshot. That includes the Device check behind `Exec`, `WrapSealingKey`, `SetDevice`, `SetPasskey` and `RemoveTrustedKey`, and the trusted keys every signed statement verifies against. A slot outside the snapshot is answered as if absent. Later enrollments through `ployz device approve` are checked under enforcement, so they enter the snapshot with evidence. The Management Client slot Cloud holds keeps Operator scope only, as it does today. The CLI prints what it retired, per Server, before it asks the person to confirm the set. The text goes through the `ui-copy` skill. @@ -194,7 +211,7 @@ The program runs the way `c-plan.md` "Program checklist" says, from arming and t - [ ] Dependency graph. F1 branches from `main` now. F2 after F1 and Appendix A gaps 1 and 2. F2 merges only after the release-signing protections in the operator's checklist are applied. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gaps 7 and 8. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, and gap 6. - [ ] Hotspots. `rpc_catalog.rs` gains one row in F2, F3, F5 and F6 each. `machine_api/local.rs`, `restart_approval.rs` and `dashboard/src/server/schema.ts` are shared with C, D and E. Each F PR adds one row or one `admit_*` call and rebases promptly. -- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the trusted snapshot), `machine_api/mod.rs` (mesh scope), `local_machine/sealing.rs` (rotation), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. +- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the trusted snapshot), `machine_api/mod.rs` (mesh scope), `local_machine/sealing.rs` (rotation), `machine/record_owner.rs` and `machine/local_machine.rs` (the authority gate), `docker/stream.rs`, `management/{mod,tunnel,build_grant}.rs` and `volume_plugin/` (effect points), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. - [ ] Bets every F PR names. The stable promise, with the argument above, and from F7 the exception. Bet 8, evidence over claims, because the dashboard calls a Cluster protected only from each Machine's own answer, and refusals name the capability. Bet 6, Machine-local authority, because each Machine checks the channel, the teardown and its own activation, and reads no other Machine's record. Bet 7, Cloud drives and never owns, because Cloud stores statements it cannot make. Bet 3, bounded imperative commands, because a channel statement and a teardown are evidence for one command, not desired state. - [ ] Residuals each F PR names once. See "What remains". - [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`. From F1 it takes its route cases from `coverage_cases` at the head under test. It is also extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. @@ -399,7 +416,7 @@ Attack path closed. Cloud cannot get a Machine of its own into the activated Clu - a Member or Device key Cloud planted while enforcement was off, including one that was in place before any Server ran F6's daemon; - a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match; - a Member added to a set the passkey signs in the dashboard; -- a planted Device or passkey key calling `Exec`, `WrapSealingKey` or `SetDevice` directly after activation, including over a connection it opened before; +- a planted Device or passkey key calling `Exec`, `WrapSealingKey` or `SetDevice` directly after activation, including over a connection it opened before, through a command written to a shell it opened before, through a mutation it queued before, or through a Build Grant it minted before; - a past holder of the Sealing Key opening values sealed after activation. A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. @@ -415,7 +432,11 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. - [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. - [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named as pinned in the signed set. It writes each retired Device key a Cleared tombstone in the same save that records the activated set. -- [ ] Edit `core/crates/ployzd/src/management/mod.rs` so each connection keeps the scope its key had when proven, and `revoked` (`:428`) completes when the published record's `management_scope` for that key is narrower, not only when `admits_management_client` (`machine/mod.rs:452`) turns false. Each stream on a revoked connection ends with the existing drain, `Exec` and attach included. Edit `ployzd/src/docker/stream.rs` (`:95`) so an `Exec` stream ends when its connection is revoked, rather than finishing its current stdin loop. +- [ ] Add the authority gate to `core/crates/ployzd/src/machine/record_owner.rs`: an `RwLock` around an epoch. Writes that narrow a key's authority take the write half across save and publish (`:229`) and bump the epoch. Activation first takes `admission_lock` exclusively. +- [ ] Edit `core/crates/ployzd/src/machine/local_machine.rs` so `require_management_access` (`:396`) takes the read half after the admission turn is held (`:438` to `:454`), and checks that the key still holds the scope the call's headers captured, not only that it is accepted. +- [ ] Edit `core/crates/ployzd/src/docker/stream.rs` (`:95`) so each stdin write and output frame of an `Exec` or attach passes the gate. Edit `core/crates/ployzd/src/management/tunnel.rs` and the Volume copy and transfer data paths in `core/crates/ployzd/src/volume_plugin/` to check per chunk. +- [ ] Edit `core/crates/ployzd/src/management/build_grant.rs` so `Grant` records the minting key and epoch (`:74` to `:92`), activation clears `BuildGrants` inside the gated write, and every grant request, `/ployz/sealed` (`:449`) included, passes the gate with the minter's key. +- [ ] Edit `core/crates/ployzd/src/management/mod.rs` so `revoked` (`:428`) also fires when a key's scope narrows. It is cleanup, not the boundary. - [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. - [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. @@ -446,8 +467,14 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - After that activation, the planted Device key's slot is gone and holds a Cleared tombstone, in the same saved record that marks the set activated. - The planted key then calls `Exec`, `WrapSealingKey` and `SetDevice` directly, and each is refused. - A connection the planted key opened before activation is closed with `REVOKED`, and a call already streaming on it ends. - - The duplicate-slot case. The planted key also holds Cloud's Operator slot, through `approve_device` from the planted Machine. Before activation it opens an interactive `Exec` shell. Activation retires its Device slot, keeps its Operator slot, and closes the connection with `REVOKED`. The shell's stream ends, and a command written to it after the activation save never runs. A fresh dial with that key gets Operator scope, and its `Exec` is refused at dispatch. - - `ployz device rm` of a Device key that also holds an Operator slot closes that key's connection the same way. A record change that leaves a key's scope as it was closes nothing. + - The duplicate-slot case. The planted key also holds Cloud's Operator slot, through `approve_device` from the planted Machine. Before activation it opens an interactive `Exec` shell. Activation retires its Device slot and keeps its Operator slot. A fresh dial with that key gets Operator scope, and its `Exec` is refused at dispatch. +- [ ] `ployzd` test `authority_gate_orders_effects_against_activation`, with controlled scheduling. Pause hooks sit in the record owner between save and publish, in the watcher before it fires, and in each effect point between its check and its act. Each case is run at every interleaving of those hooks, and also with the RPC cancelled at each one. + - A stdin command on the duplicate-slot key's pre-activation shell, queued right at the save, either reaches Docker wholly before the save or is refused. No interleaving lets it run after the save. A marker file the command would create is absent. + - An unsigned `EnsureContainer` create, submitted while the key held Full and queued behind activation's exclusive admission, is refused once admitted. No Container exists, including when its RPC was cancelled. + - A port-forward chunk and a Volume copy chunk written after the save are not delivered. + - A write that leaves every key's scope unchanged bumps no epoch, and refuses nothing. +- [ ] `build_grant` test `a_grant_minted_under_full_dies_at_activation`. The planted Device mints a grant for a holder key before activation. After activation, `GET /ployz/sealed` and a push with that grant are each refused, and no opened value is returned. A grant minted after activation by a key that `ployz device rm` then removes is refused at its next request. The existing test at `build_grant.rs:899` keeps passing for a grant minted after activation by a Device in the snapshot. + - `ployz device rm` of a Device key that also holds an Operator slot refuses that key's next effect the same way, and the watcher then closes its connection. - A legacy key named in `kept_keys` survives and keeps Full scope. - A crash before the save leaves the record not activated with every slot in place. A crash after it leaves it activated with the slots retired. No state holds one change without the other. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. @@ -481,7 +508,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - After activation, a value set afterwards does not open on the harness Machine. - The CLI's output lists the harness key as retired on every Server. - Before activation, the harness Device opens a direct connection to web-1. After activation, that connection is closed with `REVOKED`. - - The duplicate-slot case. While enforcement is off, the harness Machine approves Cloud's own management key as a Device on web-2, so that key holds both the Device slot and Cloud's Operator slot. That key opens an interactive `Exec` shell on web-2 before activation. Save `f6-dual-slot-exec.png`. Pass when, after activation, the shell ends, or its next command is refused and the command's effect is absent on web-2. Cloud's ordinary Operator work on web-2 must still succeed after it dials again. The harness Device then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. + - The duplicate-slot case. While enforcement is off, the harness Machine approves Cloud's own management key as a Device on web-2, so that key holds both the Device slot and Cloud's Operator slot. That key opens an interactive `Exec` shell on web-2 before activation, and keeps writing a command that creates a timestamped marker file every 100 ms throughout activation. Save `f6-dual-slot-exec.png` and the marker listing. Pass when no marker on web-2 is stamped after the activation save's timestamp in web-2's journal. A shell that ends is not enough on its own. Cloud's ordinary Operator work on web-2 must still succeed after it dials again. + - The grant case. Before activation, the harness Device mints a Build Grant on web-1 for a holder key the harness controls. After activation, the holder calls `GET /ployz/sealed` and pushes. Save `f6-grant.png`. Pass when both are refused, and no sealed value's plaintext appears in the holder's output. - Re-adding a legitimate retired Device with `ployz device request` and `ployz device approve` restores its Full scope. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. @@ -491,7 +519,7 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev **Verify, perf.** -- [ ] Metric. Activation of three Servers over SSH, from command to every Server reporting activated, rotation included. Rule: under 15 s. Re-sealing 100 stored values. Rule: under 5 s. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. +- [ ] Metric. Activation of three Servers over SSH, from command to every Server reporting activated, rotation included. Rule: under 15 s. Re-sealing 100 stored values. Rule: under 5 s. Volume copy throughput and `Exec` round trip through the authority gate, trunk against head, ten each, interleaved. Rule: within 5 % at the median. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. **Review gate.** The operator reviews the guided upgrade, the CLI redirect, activation and the new recovery code with screenshots and a video before merge. @@ -556,7 +584,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - `SetDeviceEnforcement` admitting a set over the local socket and recording its signer as installed over local administration. - The Device's known-Machines file, recorded at `ApproveJoin`. - Retiring every Device slot and trusted passkey outside the snapshot, in the same write that records the activated set, with `kept_keys` on `StartingSet`. And `management_scope` and statement verification reading only snapshot keys once the record is activated. - - Revoking a management connection when its key's scope narrows, not only when the key leaves every slot. One key may hold a Device slot and an Operator slot at once (`store.rs:791`), so retiring the Device slot alone must still end that key's Full connections and their long-lived streams. A agrees that `ManagementScope` is the unit revocation compares, and that a key may keep both slots. + - The authority gate. Every write that narrows a key's authority holds its write half across save and publish, and bumps an epoch. Every effect authorized by a key's scope rechecks that scope under its read half before acting: stdin and output frames, tunnel and copy chunks, mutation admission, and Build Grant use. Grants record their minting key and end at activation. One key may still hold a Device slot and an Operator slot, because the gate checks the scope each effect was authorized under, not which slots hold the key. A agrees that `ManagementScope` plus the epoch is what the check compares, and C and E review the effect points in their paths (mutation admission, Volume transfer). A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). 8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. @@ -584,7 +612,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. - **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. -- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). +- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), and the watcher sees the change later (`ployzd/src/management/mod.rs:430`). A call's scope is fixed in its headers on arrival (`ployzd/src/machine_api/mod.rs:263` to `:303`), and mutation admission checks only that the key is still accepted (`ployzd/src/machine/local_machine.rs:396`) before spawning work detached from the RPC (`:438` to `:454`). A Build Grant records only `Minter::Full` (`ployzd/src/management/build_grant.rs:74` to `:92`), enforcement refuses only Operator-minted grants (`:364`), the grant ALPN skips revocation (`ployzd/src/management/mod.rs:259`), and the holder reads opened values at `/ployz/sealed` (`build_grant.rs:449`). The tunnel checks Full scope once, at open (`ployzd/src/management/tunnel.rs:49`). - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: From e8fafcf048b5105457cf35f37196faba4a97aabf Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 12:51:51 +0000 Subject: [PATCH 7/9] Make activation a process boundary instead of gating effect points Each review round found another effect that outlived the activation write. Activation now writes a pending record and restarts the daemon. The new process stops the Volume plugin, re-verifies join approvals and upgrade attempts, and commits the activated record before it serves anything. No connection, grant, worker or want from before the commit survives it. --- docs/agents/plans/security-0.4/f-plan.md | 121 ++++++++++++++--------- 1 file changed, 73 insertions(+), 48 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index 57340f834e..08828bb92f 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -100,27 +100,27 @@ Activation is today's `ployz device enforce` with five changes. It promotes noth **Retiring legacy keys.** Refusing a planted key as the signer is not enough. Today `management_scope` gives Full scope to any key that holds a Device slot (`ployzd/src/machine/mod.rs:440`). And `set_device_enforcement` replaces the trusted Members but leaves the Device slots alone (`ployzd/src/machine/store.rs:902`). A planted Device key would still call `Exec`, `WrapSealingKey` or `SetDevice` directly after a legitimate activation. So activation does three things. -- **One atomic write.** The write that records the activated set also retires every Device slot and every trusted passkey whose key isn't in the trusted snapshot. A passkey holds no slot, since it only approves, but statements verify against it. Three kinds of key survive. The first is the activation's signer. The second is a key with evidence. The third is a key the person pinned for this activation: the signed set names it, and the CLI pinned it as described below. Each retired key gets a Cleared tombstone, so a later dial is told it was cleared. Activation never splits into an activated record that still holds legacy slots. -- **Authority is checked where each effect happens, under the lock activation writes under.** Closing connections can't be the boundary, because it happens after the write. The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), but the revocation watcher notices only later (`ployzd/src/management/mod.rs:430`). Three kinds of Full authority survive that gap. - - **Exec input.** An `Exec` or attach stream forwards stdin to Docker with no further check (`ployzd/src/docker/stream.rs:95`). A command written after the save still runs. - - **Mutations already submitted.** A call's scope is captured into headers when it arrives (`ployzd/src/machine_api/mod.rs:263` to `:303`). `EnsureContainer` skips approval for a captured Full scope (`ployzd/src/machine_api/local.rs:565`). Admission then checks only that the key is still accepted (`require_management_access`, `ployzd/src/machine/local_machine.rs:396`), and the admitted work is spawned apart from its RPC (`:438` to `:454`). An unsigned create submitted while the key was Full waits behind activation, then runs under the Operator slot that survives. - - **Build Grants.** A grant records only `Minter::Full`, not which key minted it (`ployzd/src/management/build_grant.rs:74` to `:92`). Enforcement refuses only Operator-minted grants (`:364`). The grant ALPN skips connection revocation (`ployzd/src/management/mod.rs:259`). Its holder reads opened values from `GET /ployz/sealed` (`build_grant.rs:449`). A Device planted before activation could mint a grant for a key Cloud holds and read sealed values afterwards. - - F6 closes all three with one mechanism, an **authority gate** the record owner holds. - - **The write.** The gate is an `RwLock` around an authority epoch. Any record write that can narrow a key's authority takes the gate's write half for its save and publish together, and bumps the epoch. That covers activation, retiring or removing a Device slot or passkey, and `RemoveTrustedKey`. Activation also takes Machine admission exclusively first (`admission_lock`), so all admitted work finishes before the save and no queued work starts during it. - - **The effect points.** Each effect authorized by a key's scope records the key, the scope it was authorized under, and the epoch when it was authorized. Before it acts, it takes the gate's read half. If the epoch moved, it rechecks that the current record still gives the key that scope, and only then acts, still holding the read half. The effect points are: - - each `Exec` or attach stdin write and output frame; - - each chunk on the port-forward tunnel (`ployzd/src/management/tunnel.rs:49` checks scope only at open) and on Volume copy and transfer data paths; - - mutation admission, where `require_management_access` becomes "the key still holds the scope this call was authorized under", checked after the call takes its admission turn; - - every Build Grant admission and request, `/ployz/sealed` and push included. - - A failed check ends the stream or refuses the call, naming `ployz.machine.enforced.v1`. The common case is one atomic load of the epoch. A read half is held only for one write or one admission, so activation waits no longer than one chunk. - - **Build Grants.** A grant records the key that minted it and the epoch. Activation ends every live grant inside the gated write. Later, a grant whose minting key no longer holds the scope it minted under is refused at its next request. - - **The watcher.** It stays, as cleanup. It closes connections whose scope narrowed, so they stop holding resources. It is not the boundary. - - *Why one gate and not three patches.* The bar is crossed at the moment of an effect, not at a connection. One rule ("check the current authority, under the lock the narrowing write takes, before acting") covers stdin, queued mutations and grants. It also covers any later effect point and any later write that narrows authority, with no list of special cases to keep in step. A simpler fix was weighed and rejected: closing every connection synchronously inside the activation write. It still leaves spawned admitted work and grants, which don't ride a management connection, so it needs the per-effect check anyway. - - Commands a shell started before activation keep running, as "What activation cannot undo" says. After the save, such a shell can't receive input or send output. +- **One atomic write, made by a fresh process.** The write that activates the record also retires every Device slot and every trusted passkey whose key isn't in the trusted snapshot. A passkey holds no slot, since it only approves, but statements verify against it. Three kinds of key survive. The first is the activation's signer. The second is a key with evidence. The third is a key the person pinned for this activation: the signed set names it, and the CLI pinned it as described below. Each retired key gets a Cleared tombstone, so a later dial is told it was cleared. Activation never splits into an activated record that still holds legacy slots. The write is made only by a daemon process that has served nothing yet. The next point says why. +- **Activation is a process boundary.** Five verifier rounds each found Full authority that outlived the activation write: a stream, a queued call, a grant, or an in-memory want that had been accepted before the write. Listing and gating effect points was the premise that kept failing. So F6 moves the boundary instead. + 1. `SetDeviceEnforcement` verifies the signed set. It then writes a **pending activation** (the set, `kept_keys`, and the slots and passkeys to retire), syncs it to disk, and stops taking new management work with a typed `Activating` refusal naming `ployz.machine.enforced.v1`. Then it asks for a restart through the existing `request_restart` (`ployzd/src/machine/record_owner.rs:239`). Nothing is activated yet. Whatever the old process does until it exits, it does before activation. + 2. systemd brings `ployzd` back (`Restart=always`, `RestartSec=2`, `ployzd/src/installer/host.rs:284`). Every management connection and in-flight RPC dies with the old process. So does every pending `Exec` Config wait, every Build worker, every Build Grant (they live only in memory, `ployzd/src/management/build_grant.rs:70`), and every in-memory accepted want. + 3. On boot, before it serves anything, the new process finds the pending activation. It stops the Volume plugin unit, which runs apart from `ployzd` (`host.rs:355`), so systemd ends its cgroup and any `zfs send` or receive in it. Then it re-verifies durable state (below), makes the one atomic write, removes the pending file, starts the Volume plugin again, and serves. The journal line for that write is the activation's moment. The CLI reports a Server activated only after that Server says so. + + The commit happens in a process that has never held a pre-activation connection, grant, worker or want, so none can act after it. No effect-point list is needed. A crash before the pending file is synced leaves nothing activated, and the CLI retries. A crash after it leaves the pending file for the next boot to finish. + + **What Cloud sees.** The Server's management connections drop, and calls in flight fail with a connection error or `Activating`. The Server is back in about three to five seconds, and Cloud reconnects and retries. Local callers wait rather than fail, because systemd holds `ployz.socket` across the restart. Containers, WireGuard, which is a kernel interface, and the Ingress Proxy keep running. The mesh's replicated store pauses for those seconds and catches up. The Servers in a Cluster activate one at a time, so the Cluster never loses every daemon at once. + + **Durable state on boot.** I listed every file the daemon writes to its data directory on `main`. Each is checked before the commit. + - `machine.json` is rewritten by the commit itself. + - `join-approvals.json` (`ployzd/src/machine/local_machine/join_approval.rs:44`) holds signed `JoinApproval`s that a joiner's confirmation can still publish. Each one keeps its signer, so it is re-verified against the trusted snapshot's keys and dropped unless it passes. + - `upgrade-attempt.json` (`ployzd/src/installer/upgrade.rs:20`) holds an attempt Cloud may have asked for under `AnyRelease`. One that has not reached its transient worker is kept only if it names the release the signed channel resolves to. A worker already running installs a Ployz-signed release forward-only, the `Bound` reason in f-inventory.md. + - `restart-approvals.json` is a consumption log. It only refuses replays and admits nothing, so it stays. + - The replicated store (`store.db`) carries Member rows, which the snapshot governs, and signed rules and approvals. Those are verified at use against the trusted snapshot's keys, so a row a legacy Member wrote admits nothing. + - The observation files and the Sealing Key are not authority. `sealed.json` (`ployzd/src/build/sealed.rs`) holds a capture's values still sealed. The Sealing Key rotates. + + Nothing on `main` holds an accepted effect without the signed statement behind it. The in-memory forms that do (`restart_approval.rs:134` to `:139`, `deploy_approval.rs:54` to `:73`) die with the process. A later thread could add a durable record that stores an accepted want without its signature, so F6 adds a test that lists the data directory's files and fails on any file without a boot rule. + + After the restart, no check is needed at effect points. A connection proves its key against the activated record, and every scope and statement check reads only the trusted snapshot (next point). A later `ployz device rm` is a person removing their own key, so it is not a path across the bar, and the existing watcher handles it as it does today. - **Every later Device check reads the snapshot.** From activation on, `management_scope` gives Full scope only to a slot whose key is in the trusted snapshot. That includes the Device check behind `Exec`, `WrapSealingKey`, `SetDevice`, `SetPasskey` and `RemoveTrustedKey`, and the trusted keys every signed statement verifies against. A slot outside the snapshot is answered as if absent. Later enrollments through `ployz device approve` are checked under enforcement, so they enter the snapshot with evidence. The Management Client slot Cloud holds keeps Operator scope only, as it does today. The CLI prints what it retired, per Server, before it asks the person to confirm the set. The text goes through the `ui-copy` skill. @@ -156,7 +156,7 @@ A person keeps a key only by confirming its fingerprint against `ployz device ls The rotation is keyed by the activation's operation ID, so a resend after a crash converges on the same new key (thread A contract, gap 8). The alternative is to rotate only when activation left a Member out. It was rejected because a past holder may no longer be a Member at all. -**What activation cannot undo.** Values sealed before activation were open to any Machine that held the old key, and Cloud holds their ciphertext. Rotation protects values set after it. Before activation, a Machine Cloud enrolled could also call Full routes, `Exec` among them, on every other Machine. The 0.4 upgrade guide says both plainly. A person who doubts the Cluster's history changes their secrets at the source. Neither is a residual of F, because both happened before any 0.4 promise held. +**What activation cannot undo.** Values sealed before activation were open to any Machine that held the old key, and Cloud holds their ciphertext. Rotation protects values set after it. Before activation, a Machine Cloud enrolled could also call Full routes, `Exec` among them, on every other Machine. Anything already handed to Docker, the kernel or systemd before the activation write keeps running. That covers a started `exec` and the commands its shell had already received, a created Container, a running build container, and a transient upgrade worker. The restart ends their connection to the old daemon, so a shell gets no more input and returns no more output, but a command already running can finish. The 0.4 upgrade guide says all of this plainly. A person who doubts the Cluster's history changes their secrets at the source. Neither is a residual of F, because both happened before any 0.4 promise held. `ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, publishes an approved joiner only after the joiner's confirmation (#4025), and keeps the trusted snapshot above. Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. @@ -211,7 +211,7 @@ The program runs the way `c-plan.md` "Program checklist" says, from arming and t - [ ] Dependency graph. F1 branches from `main` now. F2 after F1 and Appendix A gaps 1 and 2. F2 merges only after the release-signing protections in the operator's checklist are applied. F3 after F1 and gap 2. F4 after F1 and D6 merged (a built-image rollback is D6's statement). F5 after F1, gaps 3 to 5, and E6 merged (gap 5). F6 after F2 to F5, D4, E8 and gaps 7 and 8. It is the C4 integration slice. F7 after F6, thread R's findings at C4 resolved or accepted, and gap 6. - [ ] Hotspots. `rpc_catalog.rs` gains one row in F2, F3, F5 and F6 each. `machine_api/local.rs`, `restart_approval.rs` and `dashboard/src/server/schema.ts` are shared with C, D and E. Each F PR adds one row or one `admit_*` call and rebases promptly. -- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the trusted snapshot), `machine_api/mod.rs` (mesh scope), `local_machine/sealing.rs` (rotation), `machine/record_owner.rs` and `machine/local_machine.rs` (the authority gate), `docker/stream.rs`, `management/{mod,tunnel,build_grant}.rs` and `volume_plugin/` (effect points), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. +- [ ] File boundaries. F1 touches `core/crates/ployz-core/src/authority/{coverage,mod}.rs` (new module), `core/crates/ployz-core/tests/capabilities.rs`, `core/crates/ployzd/src/machine_api/{local,authorization_tests}.rs`, `core/crates/ployzd/src/machine/local_machine/restart_approval.rs` (`admit_operator_departure`), `core/crates/ployz/src/handlers/server/` (refusal copy) and `docs/user/servers/manage-servers.md`. F2 adds `core/crates/ployz-core/src/authority/upgrade.rs` and touches `core/crates/ployz-core/src/rpc/upgrade.rs` (one field), `rpc_catalog.rs`, `core/crates/ployzd/src/machine/local_machine/upgrade.rs`, `core/crates/ployzd/src/installer/upgrade.rs` (`ReleaseChoice`), `restart_approval.rs` (one record kind), the `ployz server upgrade-policy` handler, `dashboard/src/modules/server-upgrade/**` and the docs. F3 touches `machine_api/local.rs` (`set_ingress_doors`, `set_private_access`), `core/crates/ployz-core/src/authority/` (`PrivateAccessApproval`), `dashboard/src/modules/{ingress,private-access}/**` and the docs. F4 touches only `dashboard/src/modules/{config-store,approval-session,service-act}/**` and `docs/user/deploy/**`. F5 adds `core/crates/ployz-core/src/authority/teardown.rs` and touches `deploy_approval.rs`, `machine_api/local.rs` (`leave`, `forget`, `remove_volume` hand-off to E6's check), `core/crates/ployz/src/deploy/pipeline.rs` (`destroy_namespace`), `dashboard/src/modules/machines/{namespace-cleanup,machine-removal}*`, and the docs. F6 touches `core/crates/ployz/src/handlers/device.rs` (pins and the known-Machines file), `core/crates/ployzd/src/machine/` (the trusted snapshot), `machine_api/mod.rs` (mesh scope), `local_machine/sealing.rs` (rotation), `daemon.rs` and `machine/store.rs` (the boot commit), `restart_approval.rs` (`set_device_enforcement`), `rpc_catalog.rs`, `dashboard/src/modules/server-upgrade/**`, `dashboard/src/modules/machines/server-policy*` and the docs. F7 touches every file in f-inventory.md "Where the switch lives", `core/DESIGN.md`, `core/crates/ployzd/src/machine/store.rs` (`member`, `founder`) and `docs/user/servers/{add-a-server,manage-servers}.md`. - [ ] Bets every F PR names. The stable promise, with the argument above, and from F7 the exception. Bet 8, evidence over claims, because the dashboard calls a Cluster protected only from each Machine's own answer, and refusals name the capability. Bet 6, Machine-local authority, because each Machine checks the channel, the teardown and its own activation, and reads no other Machine's record. Bet 7, Cloud drives and never owns, because Cloud stores statements it cannot make. Bet 3, bounded imperative commands, because a channel statement and a teardown are evidence for one command, not desired state. - [ ] Residuals each F PR names once. See "What remains". - [ ] Live lanes run per `c-plan.md` "Boot recipe", with `--machines 3` where a lane says so. The attacker harness is C1's `.audit/c-attacker/`. From F1 it takes its route cases from `coverage_cases` at the head under test. It is also extended with `Leave`, `Forget`, `RequestMachineUpgrade`, `SetIngressDoors`, `SetPrivateAccess`, a stripped enroll response and a forged one. @@ -416,12 +416,12 @@ Attack path closed. Cloud cannot get a Machine of its own into the activated Clu - a Member or Device key Cloud planted while enforcement was off, including one that was in place before any Server ran F6's daemon; - a key swapped behind a Server's name, where Cloud supplies both the connection's identity and an `Inspect` answer to match; - a Member added to a set the passkey signs in the dashboard; -- a planted Device or passkey key calling `Exec`, `WrapSealingKey` or `SetDevice` directly after activation, including over a connection it opened before, through a command written to a shell it opened before, through a mutation it queued before, or through a Build Grant it minted before; +- a planted Device or passkey key calling `Exec`, `WrapSealingKey` or `SetDevice` directly after activation, including over a connection it opened before, or through any stream, queued call, worker, accepted want or Build Grant it opened before, all of which end with the old process; - a past holder of the Sealing Key opening values sealed after activation. A Server too old to enforce cannot sit in an enforcing Cluster (#3980). An admitting Machine from before #4025 cannot publish a joiner under Cloud's Devices (A1's older-admitter window). This slice is the C4 integration run. -What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Device and SSH to each Server once, to activate. Once F6's daemon starts, a Device's Full call no longer works through another Machine. It needs a direct connection, as it already does under enforcement. Activation prints a new recovery code, and the old one stops opening anything sealed after activation. Each Device or passkey added while enforcement was off is removed unless the person confirms its fingerprint, and comes back through `ployz device request` and `ployz device approve`. +What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Device and SSH to each Server once, to activate. Once F6's daemon starts, a Device's Full call no longer works through another Machine. It needs a direct connection, as it already does under enforcement. Activation prints a new recovery code, and the old one stops opening anything sealed after activation. Each Device or passkey added while enforcement was off is removed unless the person confirms its fingerprint, and comes back through `ployz device request` and `ployz device approve`. Each Server's daemon restarts once during activation, so its management API is down for a few seconds while workloads keep serving. **Depends on.** F2 to F5. D4 and E8 merged. Appendix A gaps 7 (evidence, the trusted snapshot and local-socket activation) and 8 (Sealing Key rotation). @@ -431,12 +431,10 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Edit `core/crates/ployzd/src/machine_api/mod.rs` (`:185`) so a daemon that advertises `ployz.machine.enforced.v1` never gives a non-local mesh caller Full scope, whether or not it enforces. - [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. - [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. -- [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named as pinned in the signed set. It writes each retired Device key a Cleared tombstone in the same save that records the activated set. -- [ ] Add the authority gate to `core/crates/ployzd/src/machine/record_owner.rs`: an `RwLock` around an epoch. Writes that narrow a key's authority take the write half across save and publish (`:229`) and bump the epoch. Activation first takes `admission_lock` exclusively. -- [ ] Edit `core/crates/ployzd/src/machine/local_machine.rs` so `require_management_access` (`:396`) takes the read half after the admission turn is held (`:438` to `:454`), and checks that the key still holds the scope the call's headers captured, not only that it is accepted. -- [ ] Edit `core/crates/ployzd/src/docker/stream.rs` (`:95`) so each stdin write and output frame of an `Exec` or attach passes the gate. Edit `core/crates/ployzd/src/management/tunnel.rs` and the Volume copy and transfer data paths in `core/crates/ployzd/src/volume_plugin/` to check per chunk. -- [ ] Edit `core/crates/ployzd/src/management/build_grant.rs` so `Grant` records the minting key and epoch (`:74` to `:92`), activation clears `BuildGrants` inside the gated write, and every grant request, `/ployz/sealed` (`:449`) included, passes the gate with the minter's key. -- [ ] Edit `core/crates/ployzd/src/management/mod.rs` so `revoked` (`:428`) also fires when a key's scope narrows. It is cleanup, not the boundary. +- [ ] Edit `restart_approval.rs` so an admitted set is not applied in place. It writes and syncs a pending activation file, refuses new management work with `Error::Activating` naming `ployz.machine.enforced.v1`, and calls `request_restart` (`ployzd/src/machine/record_owner.rs:239`). +- [ ] Edit the daemon's boot path in `core/crates/ployzd/src/daemon.rs` so that, before it serves anything, a pending activation is finished in this order. Stop the Volume plugin unit. Re-verify `join-approvals.json` against the snapshot's keys and drop what fails. Keep an unstarted `upgrade-attempt.json` only if it matches the signed channel. Make the one atomic write. Remove the pending file. Start the Volume plugin again. +- [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) becomes that one atomic write, called only from boot. It activates the record and retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named in `kept_keys`, with a Cleared tombstone for each retired Device key. +- [ ] Add a test in `core/crates/ployzd/src/` that lists every file the daemon writes to its data directory and fails on any file with no boot rule for a pending activation. - [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. - [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. @@ -464,19 +462,37 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A set signed by the passkey and naming the harness Member, sent over a Device connection, is refused naming the Member. - A set signed by the planted key is refused naming the key. - The same set without the harness Member, sent over the local socket, is admitted, and its signer is recorded as installed over local administration. - - After that activation, the planted Device key's slot is gone and holds a Cleared tombstone, in the same saved record that marks the set activated. + - After that activation and its restart, the planted Device key's slot is gone and holds a Cleared tombstone, in the same saved record that marks the set activated. - The planted key then calls `Exec`, `WrapSealingKey` and `SetDevice` directly, and each is refused. - - A connection the planted key opened before activation is closed with `REVOKED`, and a call already streaming on it ends. - - The duplicate-slot case. The planted key also holds Cloud's Operator slot, through `approve_device` from the planted Machine. Before activation it opens an interactive `Exec` shell. Activation retires its Device slot and keeps its Operator slot. A fresh dial with that key gets Operator scope, and its `Exec` is refused at dispatch. -- [ ] `ployzd` test `authority_gate_orders_effects_against_activation`, with controlled scheduling. Pause hooks sit in the record owner between save and publish, in the watcher before it fires, and in each effect point between its check and its act. Each case is run at every interleaving of those hooks, and also with the RPC cancelled at each one. - - A stdin command on the duplicate-slot key's pre-activation shell, queued right at the save, either reaches Docker wholly before the save or is refused. No interleaving lets it run after the save. A marker file the command would create is absent. - - An unsigned `EnsureContainer` create, submitted while the key held Full and queued behind activation's exclusive admission, is refused once admitted. No Container exists, including when its RPC was cancelled. - - A port-forward chunk and a Volume copy chunk written after the save are not delivered. - - A write that leaves every key's scope unchanged bumps no epoch, and refuses nothing. -- [ ] `build_grant` test `a_grant_minted_under_full_dies_at_activation`. The planted Device mints a grant for a holder key before activation. After activation, `GET /ployz/sealed` and a push with that grant are each refused, and no opened value is returned. A grant minted after activation by a key that `ployz device rm` then removes is refused at its next request. The existing test at `build_grant.rs:899` keeps passing for a grant minted after activation by a Device in the snapshot. - - `ployz device rm` of a Device key that also holds an Operator slot refuses that key's next effect the same way, and the watcher then closes its connection. + - The duplicate-slot case. The planted key also holds Cloud's Operator slot, through `approve_device` from the planted Machine. After activation it dials again, gets Operator scope, and its `Exec` is refused at dispatch. - A legacy key named in `kept_keys` survives and keeps Full scope. - - A crash before the save leaves the record not activated with every slot in place. A crash after it leaves it activated with the slots retired. No state holds one change without the other. +- [ ] `ployzd` test `activation_is_a_process_boundary`. It runs the daemon in-process with a restart hook, and uses controlled scheduling across the pending write, the old process's exit, the boot commit, and RPC cancellation. Before the pending write, it opens: + - an `Exec` whose Config is withheld; + - an interactive `Exec` shell on the duplicate-slot key; + - a raw `Build` whose Dockerfile is withheld; + - an `InspectContainer` held in Docker; + - an unsigned `EnsureContainer` create, paused at admission and cancelled; + - a Build Grant minted by the planted Device; + - a pending accepted restart want. + + Pass when, after the boot commit, none of these has any effect. Specifically: + - the Config and Dockerfile sent after the commit reach nothing; + - no `exec` is created after the commit; + - the inspect returns no plaintext value; + - no Container is created; + - `GET /ployz/sealed` and a push with the grant are refused; + - no restart runs. + + Also check: + - The new process holds no connection, grant, worker or want from before the commit. + - A stdin write tagged with a nonce issued after the commit never reaches Docker. + - A crash after the pending file is synced but before the commit leaves the record not activated with every slot in place, and the next boot finishes it. + - A crash after the commit leaves the record activated, with the slots retired and the pending file gone or ignored. +- [ ] `ployzd` test `boot_reverifies_durable_state`. + - A `join-approvals.json` entry signed by the planted key is dropped at the commit, and one signed by a kept key stays. + - An unstarted `upgrade-attempt.json` naming a release off the signed channel is dropped. + - The Volume plugin unit is stopped before the commit and started after. + - The data-directory file list test passes. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. - [ ] `ployzd` test `a_mesh_caller_is_never_full_on_an_enforced_v1_daemon`. With enforcement off, a mesh `SetDevice` is refused at dispatch. - [ ] `ployzd` tests for rotation. @@ -507,10 +523,18 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - `ployz device enforce` names the harness Member and leaves it out unless the person confirms its fingerprint. - After activation, a value set afterwards does not open on the harness Machine. - The CLI's output lists the harness key as retired on every Server. - - Before activation, the harness Device opens a direct connection to web-1. After activation, that connection is closed with `REVOKED`. - - The duplicate-slot case. While enforcement is off, the harness Machine approves Cloud's own management key as a Device on web-2, so that key holds both the Device slot and Cloud's Operator slot. That key opens an interactive `Exec` shell on web-2 before activation, and keeps writing a command that creates a timestamped marker file every 100 ms throughout activation. Save `f6-dual-slot-exec.png` and the marker listing. Pass when no marker on web-2 is stamped after the activation save's timestamp in web-2's journal. A shell that ends is not enough on its own. Cloud's ordinary Operator work on web-2 must still succeed after it dials again. - - The grant case. Before activation, the harness Device mints a Build Grant on web-1 for a holder key the harness controls. After activation, the holder calls `GET /ployz/sealed` and pushes. Save `f6-grant.png`. Pass when both are refused, and no sealed value's plaintext appears in the holder's output. + - Before activation, it opens a set of half-open streams to web-2 with the harness Device key, which also holds Cloud's Operator slot there: + - an `Exec` with its Config withheld; + - an interactive `Exec` shell; + - a raw `Build` with its Dockerfile withheld; + - an `InspectContainer` held in flight by a paused Docker proxy; + - an unsigned `EnsureContainer` create paused at admission. + + It also mints a Build Grant on web-1 for a holder key the harness controls. Save `f6-half-open.png` and the harness log. + - The harness watches web-2's journal for the activation commit line. After it sees the line, it sends a fresh nonce through every stream it still holds: the withheld Config and Dockerfile, shell input, and a resend of the create. It also calls `GET /ployz/sealed` and pushes with the grant. Pass when no file or Container carrying a post-commit nonce exists on web-2, the inspect returned no plaintext, both grant calls are refused, and no sealed plaintext appears in the holder's output. Commands the shell received before the commit may still finish, so they don't count. + - The harness key then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. Cloud's ordinary Operator work on web-2 succeeds after it reconnects. - Re-adding a legitimate retired Device with `ployz device request` and `ployz device approve` restores its Full scope. + - Record how long each Server's management API was down during activation, and that Containers and routes served throughout. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. - [ ] Lane 8. Automatic upgrades on, a 0.4 release on the test channel. Save `f6-no-auto-cross.png`. Pass when no 0.3 Server moves to 0.4 on its own. @@ -519,7 +543,7 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev **Verify, perf.** -- [ ] Metric. Activation of three Servers over SSH, from command to every Server reporting activated, rotation included. Rule: under 15 s. Re-sealing 100 stored values. Rule: under 5 s. Volume copy throughput and `Exec` round trip through the authority gate, trunk against head, ten each, interleaved. Rule: within 5 % at the median. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. +- [ ] Metric. Activation of three Servers over SSH, from command to every Server reporting activated, rotation included. Rule: under 15 s. Re-sealing 100 stored values. Rule: under 5 s. Management API downtime per Server during activation, from the `Activating` refusal to the first answered call. Rule: under 10 s, with Containers and routes serving throughout. Deploy press to running on the activated Cluster against the same Cluster before activation, five each. Rule: within 1.2 times. **Review gate.** The operator reviews the guided upgrade, the CLI redirect, activation and the new recovery code with screenshots and a video before merge. @@ -584,7 +608,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - `SetDeviceEnforcement` admitting a set over the local socket and recording its signer as installed over local administration. - The Device's known-Machines file, recorded at `ApproveJoin`. - Retiring every Device slot and trusted passkey outside the snapshot, in the same write that records the activated set, with `kept_keys` on `StartingSet`. And `management_scope` and statement verification reading only snapshot keys once the record is activated. - - The authority gate. Every write that narrows a key's authority holds its write half across save and publish, and bumps an epoch. Every effect authorized by a key's scope rechecks that scope under its read half before acting: stdin and output frames, tunnel and copy chunks, mutation admission, and Build Grant use. Grants record their minting key and end at activation. One key may still hold a Device slot and an Operator slot, because the gate checks the scope each effect was authorized under, not which slots hold the key. A agrees that `ManagementScope` plus the epoch is what the check compares, and C and E review the effect points in their paths (mutation admission, Volume transfer). + - Activation as a process boundary. `SetDeviceEnforcement` writes a pending activation and restarts the daemon. The boot commit re-verifies `join-approvals.json` against the snapshot's keys, then retires slots and activates in one write before serving. A agrees that `JoinApproval` records keep their signer, so they can be re-verified, and that no durable record of A's stores an accepted effect without its signed statement. One key may keep both a Device slot and an Operator slot, because after the restart each connection proves its key against the activated record. A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). 8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. @@ -600,6 +624,7 @@ E's gap 7 needs nothing from F. F's maintenance list maps onto E's slices. Autom - **Making `Leave` and `Forget` `Full` in the catalog.** That would break Cloud's removal on Clusters that do not enforce. F1 refuses them only under enforcement, as D1 and E1 did. - **A pin in Cloud's install line.** Cloud writes the line and its script runs as root first. Decision 3. - **Seeding the snapshot from every Member present at the first F6 start.** Cloud can enroll its own Machine while enforcement is off, so that Machine would be in every snapshot, and a passkey-signed set would promote it. The snapshot takes only Members and keys with evidence. +- **An authority gate at every effect point.** Every narrowing write and every effect would take one lock and recheck scope. Each verifier round found an effect point the list missed: `Exec` start, raw `Build`, `InspectContainer`, accepted wants. A restart ends all of them at once, so no list has to be kept complete. - **Rotating the Sealing Key only when activation leaves a Member out.** Any Machine that was ever a Member may hold the key, including one that has already left, so the current membership says nothing about who holds it. ## Appendix C. What was checked, and what was not @@ -612,7 +637,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. - **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. -- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), and the watcher sees the change later (`ployzd/src/management/mod.rs:430`). A call's scope is fixed in its headers on arrival (`ployzd/src/machine_api/mod.rs:263` to `:303`), and mutation admission checks only that the key is still accepted (`ployzd/src/machine/local_machine.rs:396`) before spawning work detached from the RPC (`:438` to `:454`). A Build Grant records only `Minter::Full` (`ployzd/src/management/build_grant.rs:74` to `:92`), enforcement refuses only Operator-minted grants (`:364`), the grant ALPN skips revocation (`ployzd/src/management/mod.rs:259`), and the holder reads opened values at `/ployz/sealed` (`build_grant.rs:449`). The tunnel checks Full scope once, at open (`ployzd/src/management/tunnel.rs:49`). +- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), and the watcher sees the change later (`ployzd/src/management/mod.rs:430`). A call's scope is fixed in its headers on arrival (`ployzd/src/machine_api/mod.rs:263` to `:303`), and mutation admission checks only that the key is still accepted (`ployzd/src/machine/local_machine.rs:396`) before spawning work detached from the RPC (`:438` to `:454`). A Build Grant records only `Minter::Full` (`ployzd/src/management/build_grant.rs:74` to `:92`), enforcement refuses only Operator-minted grants (`:364`), the grant ALPN skips revocation (`ployzd/src/management/mod.rs:259`), and the holder reads opened values at `/ployz/sealed` (`build_grant.rs:449`). The tunnel checks Full scope once, at open (`ployzd/src/management/tunnel.rs:49`). `Exec` waits for its Config and then creates and starts the exec with no admission (`ployzd/src/docker/stream.rs:33`, `:63`, `:69`; `machine_api/local.rs:736`). A raw `Build` takes installation exclusion only and runs files it receives later (`ployzd/src/build.rs:194`, `:357`, `:432`). `InspectContainer` redacts env only for a scope captured as Operator (`machine_api/local.rs:537` to `:552`). `restart_approval.rs:134` to `:139` and `deploy_approval.rs:54` to `:73` hand on an accepted want without its signer. These are why F6 makes activation a process boundary. The durable files in the data directory are listed in "Activation". Of them, only `join-approvals.json` and `upgrade-attempt.json` can carry authority across a restart, and both can be re-verified. The Volume plugin runs as its own unit (`ployzd/src/installer/host.rs:355`), and `ployzd` restarts under `Restart=always` with `RestartSec=2` (`:284`). - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: From 492debf02d6b2f2ab8d2b9942d80d4089a840eca Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 13:00:58 +0000 Subject: [PATCH 8/9] Complete activation's durable-state inventory against main restart-approvals.json caches join replies and accepted builds that admit work, and machine.db holds the specs boot restores. The commit now re-verifies cached joins against the snapshot, drops every AcceptedBuild, and restores doors and the Private Access router only to their stored state on pinned images. The inventory test covers files, cache keys and machine.db tables. --- docs/agents/plans/security-0.4/f-plan.md | 58 ++++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index 08828bb92f..1d731e937f 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -110,15 +110,38 @@ Activation is today's `ployz device enforce` with five changes. It promotes noth **What Cloud sees.** The Server's management connections drop, and calls in flight fail with a connection error or `Activating`. The Server is back in about three to five seconds, and Cloud reconnects and retries. Local callers wait rather than fail, because systemd holds `ployz.socket` across the restart. Containers, WireGuard, which is a kernel interface, and the Ingress Proxy keep running. The mesh's replicated store pauses for those seconds and catches up. The Servers in a Cluster activate one at a time, so the Cluster never loses every daemon at once. - **Durable state on boot.** I listed every file the daemon writes to its data directory on `main`. Each is checked before the commit. - - `machine.json` is rewritten by the commit itself. - - `join-approvals.json` (`ployzd/src/machine/local_machine/join_approval.rs:44`) holds signed `JoinApproval`s that a joiner's confirmation can still publish. Each one keeps its signer, so it is re-verified against the trusted snapshot's keys and dropped unless it passes. - - `upgrade-attempt.json` (`ployzd/src/installer/upgrade.rs:20`) holds an attempt Cloud may have asked for under `AnyRelease`. One that has not reached its transient worker is kept only if it names the release the signed channel resolves to. A worker already running installs a Ployz-signed release forward-only, the `Bound` reason in f-inventory.md. - - `restart-approvals.json` is a consumption log. It only refuses replays and admits nothing, so it stays. - - The replicated store (`store.db`) carries Member rows, which the snapshot governs, and signed rules and approvals. Those are verified at use against the trusted snapshot's keys, so a row a legacy Member wrote admits nothing. - - The observation files and the Sealing Key are not authority. `sealed.json` (`ployzd/src/build/sealed.rs`) holds a capture's values still sealed. The Sealing Key rotates. - - Nothing on `main` holds an accepted effect without the signed statement behind it. The in-memory forms that do (`restart_approval.rs:134` to `:139`, `deploy_approval.rs:54` to `:73`) die with the process. A later thread could add a durable record that stores an accepted want without its signature, so F6 adds a test that lists the data directory's files and fails on any file without a boot rule. + **Durable state on boot.** These are every file in the data directory, every table in `machine.db`, and every replicated table the daemon acts on at boot, all read on `main` at `08c00fe2f8`. The boot commit applies each table's rule before it activates. A rule of "keep" means the state only refuses, bounds or describes, and admits nothing. + + | State | What it holds | Boot rule | + | --- | --- | --- | + | `machine.json` | The local record | Rewritten by the commit | + | `join-approvals.json` (`ployzd/src/machine/local_machine/join_approval.rs:44`) | Join proposals, with any signed `JoinApproval` that a confirmation can still publish | Re-verify each approval against the trusted snapshot's keys, and drop each that fails | + | `restart-approvals.json`, `joins` (`restart_approval.rs:332`) | Successful join replies: a cached registration (`local_machine.rs:927`) and cached Sealing material (`sealing.rs:179`), both returned before re-verification. Each entry keeps its full `Signed`. | Re-verify each stored approval against the snapshot's keys. Drop the entry, reply and Sealing material together, when it fails. A pending joiner then asks again under enforcement. | + | `restart-approvals.json`, `accepted` (`:239`, `:293`) | `AcceptedBuild`: generation, commit and image, with no signer. `rule_deploy.rs:100` uses it to skip the GitHub token check for a later creation. | Drop every entry, because the entry can't show its rule was signed by a snapshot key. The next Container under each rule needs a fresh token check. Every request still re-verifies the rule's own signature against the trusted keys (`rule_deploy.rs:74`), so this costs one token check per rule and Service. | + | `restart-approvals.json`, `rules` | Each rule's highest generation and fixed identity | Keep. It only refuses. A mark that a planted rule raised can refuse a real rule, which is a drop. | + | `restart-approvals.json`, `proofs`, `rule_hooks`, `operations`, `expiry_time_floor` | Consumed tokens, the hook already run per push, consumed operations, and the expiry floor | Keep. Each only refuses a replay. | + | `upgrade-attempt.json` (`ployzd/src/installer/upgrade.rs:20`) | One upgrade attempt | Keep one that hasn't reached its worker only if it names the release the signed channel resolves to. A running worker installs a Ployz-signed release, forward only. | + | `sealing.key` | The Sealing Key | Rotated after the commit | + | `sealed.json` (`ployzd/src/build/sealed.rs`) | One Build's captured values, still sealed | Delete. No Build survives the restart. | + | `account.json` (`ployzd/src/certificates.rs`) | The ACME account key | Keep. It is DNS and ACME authority, `Bound` under thread X. | + | `config.toml` (`ployzd/src/corrosion/service.rs`) | The replicated store's own configuration | Keep. The daemon writes it, and it holds no key. | + | `dns.json`, `bridge-stopped-members.json`, `bridge-member-addresses.json` | DNS and bridge state derived from the record and the store | Keep. Each is rebuilt from the activated record and the filtered `machines` table. | + | `meta.json`, `env.json`, `first-set.json`, `salts.json` (`ployzd/src/observe/`), `state.json` (`ployzd/src/installer/prepare.rs`) | Observation and install state | Keep. Not authority. | + | `machine.db` `containers` (`ployzd/src/docker/spec_store.rs:22`, written at `:423`) | Each Container's `ResolvedServiceSpec`, without its approval. Boot restores the Private Access router and the ingress doors from it. | Restore only, under the rule below. | + | `machine.db` `volume_floors`, `rotations`, `stops`, `creations` | Copy floors, rotation and stop records for existing Containers, and creation slots | Keep. Each describes or bounds an existing Container. A new creation is admitted under enforcement like any other. | + | Replicated `machines` | Members | Read through `accepts_machine` (`ployzd/src/corrosion/store.rs:731`), which under enforcement keeps only trusted Members. That is the activated set, so a left-out legacy Member is not peered. | + | Replicated `cluster` | The Cluster's name and network | Keep. Private Access reads the network from the record first (`private_access.rs:203`). | + | Replicated `ingress_routes`, `certificates`, `containers`, `volumes` | Routes, certificate material, observations and Volume placement that Caddy and status read | Keep. They are the `Bound` route and certificate data in f-inventory.md, and nothing at boot creates or starts anything from them. | + + `saved-machine.json` appears only in a test (`management_client.rs:478`). F6's test lists every file in the data directory and every table in `machine.db`, and fails on any that has no row here. + + **Boot restoration stays within what was placed before.** On boot, `restore_private_access` (`daemon.rs:532`) and `restore_doors` (`ployzd/src/machine/local_machine/ingress_doors.rs:448` to `:462`) converge Containers from `machine.db`'s stored specs. They restore what was already placed before activation, so they belong with "What activation cannot undo". The boundary is that boot may only converge back to what is stored. Any change after activation needs a signed approval checked against the snapshot. I read both paths on `main`. + - **Doors.** `restore_doors` opens the stored sealed tunnel token, writes it, and converges to `held.specs` from the Docker inventory (`:449` to `:462`). It adds no door, host or token. It does start a door that is at rest. Under enforcement off, Cloud could have set a door with an image of its choosing. So F6 adds F3's pinned-image check to `restore_doors`: a held door whose image is not the daemon's pin is removed, not started. Without that check, this path would start Cloud's code after activation. + - **Private Access.** `restore_private_access` reuses the stored router spec when its state label matches (`ployzd/src/machine/local_machine/private_access.rs:327`). Otherwise it rebuilds the spec from the stored sealed auth key, the record's Cluster network and the pinned image (`:330` to `:333`). Either way, it joins only the tailnet whose key was already sealed, and advertises only the Cluster network. The stored spec's image, though, could be one Cloud chose while enforcement was off. So F6 makes restore always rebuild from the pinned image, never reuse a stored image. + + The sealed auth key is pre-activation state, so the tailnet it joins may be one Cloud chose while enforcement was off. Restoring it changes nothing that was already running, and joins no new tailnet. So it sits in "What activation cannot undo". The activation output names a Private Access router placed before activation and its tailnet. The 0.4 upgrade guide tells a person who doesn't recognize it to turn Private Access off, then on again under F3's approval. + + Nothing durable admits work on `main` except the join cache and `AcceptedBuild` above, which the commit re-verifies or drops. The in-memory accepted wants (`restart_approval.rs:134` to `:139`, `deploy_approval.rs:54` to `:73`) die with the process. After the restart, no check is needed at effect points. A connection proves its key against the activated record, and every scope and statement check reads only the trusted snapshot (next point). A later `ployz device rm` is a person removing their own key, so it is not a path across the bar, and the existing watcher handles it as it does today. - **Every later Device check reads the snapshot.** From activation on, `management_scope` gives Full scope only to a slot whose key is in the trusted snapshot. That includes the Device check behind `Exec`, `WrapSealingKey`, `SetDevice`, `SetPasskey` and `RemoveTrustedKey`, and the trusted keys every signed statement verifies against. A slot outside the snapshot is answered as if absent. Later enrollments through `ployz device approve` are checked under enforcement, so they enter the snapshot with evidence. The Management Client slot Cloud holds keeps Operator scope only, as it does today. @@ -156,7 +179,7 @@ A person keeps a key only by confirming its fingerprint against `ployz device ls The rotation is keyed by the activation's operation ID, so a resend after a crash converges on the same new key (thread A contract, gap 8). The alternative is to rotate only when activation left a Member out. It was rejected because a past holder may no longer be a Member at all. -**What activation cannot undo.** Values sealed before activation were open to any Machine that held the old key, and Cloud holds their ciphertext. Rotation protects values set after it. Before activation, a Machine Cloud enrolled could also call Full routes, `Exec` among them, on every other Machine. Anything already handed to Docker, the kernel or systemd before the activation write keeps running. That covers a started `exec` and the commands its shell had already received, a created Container, a running build container, and a transient upgrade worker. The restart ends their connection to the old daemon, so a shell gets no more input and returns no more output, but a command already running can finish. The 0.4 upgrade guide says all of this plainly. A person who doubts the Cluster's history changes their secrets at the source. Neither is a residual of F, because both happened before any 0.4 promise held. +**What activation cannot undo.** Values sealed before activation were open to any Machine that held the old key, and Cloud holds their ciphertext. Rotation protects values set after it. Before activation, a Machine Cloud enrolled could also call Full routes, `Exec` among them, on every other Machine. Anything already handed to Docker, the kernel or systemd before the activation write keeps running. That covers a started `exec` and the commands its shell had already received, a created Container, a running build container, and a transient upgrade worker. It also covers what boot restores from `machine.db`: the ingress doors and a Private Access router placed before activation. Each is restored exactly, never widened. Doors and the router run only the daemon's pinned images, and the router joins only the tailnet it had already joined. The restart ends their connection to the old daemon, so a shell gets no more input and returns no more output, but a command already running can finish. The 0.4 upgrade guide says all of this plainly. A person who doubts the Cluster's history changes their secrets at the source. Neither is a residual of F, because both happened before any 0.4 promise held. `ployz.machine.enforced.v1` is a new capability row. A daemon advertises it only when it gates every route the coverage table classes `Signed` at that daemon's version, publishes an approved joiner only after the joiner's confirmation (#4025), and keeps the trusted snapshot above. Its meaning is fixed at F6's set and never widens. A Server too old for it is named and left out, which closes #3980. It also closes A1's older-admitter window, since an admitting Machine from before #4025 cannot be in an activated Cluster. @@ -432,9 +455,10 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Create the trusted snapshot in `core/crates/ployzd/src/machine/` (gap 7). Write it once, on the first start that advertises `ployz.machine.enforced.v1`. It holds only this Machine and the Members and keys with evidence. Extend it only on a `JoinApproval` this Machine verified. Mark each other Member and key legacy. - [ ] Edit `restart_approval.rs` so `set_device_enforcement` admits a starting set only when every Member and the signer are in the snapshot, or when it arrives over the local socket. In that case, record the signer as installed over local administration. Refuse anything else, naming each legacy Member or key. Refuse `enforced: false` once activated (`:39`), naming the capability. - [ ] Edit `restart_approval.rs` so an admitted set is not applied in place. It writes and syncs a pending activation file, refuses new management work with `Error::Activating` naming `ployz.machine.enforced.v1`, and calls `request_restart` (`ployzd/src/machine/record_owner.rs:239`). -- [ ] Edit the daemon's boot path in `core/crates/ployzd/src/daemon.rs` so that, before it serves anything, a pending activation is finished in this order. Stop the Volume plugin unit. Re-verify `join-approvals.json` against the snapshot's keys and drop what fails. Keep an unstarted `upgrade-attempt.json` only if it matches the signed channel. Make the one atomic write. Remove the pending file. Start the Volume plugin again. +- [ ] Edit the daemon's boot path in `core/crates/ployzd/src/daemon.rs` so that, before it serves anything, a pending activation is finished in this order. Stop the Volume plugin unit. Apply every boot rule in "Activation", "Durable state on boot": re-verify `join-approvals.json` and the cached join replies in `restart-approvals.json` against the snapshot's keys, dropping what fails with its Sealing material; drop every `AcceptedBuild`; keep an unstarted `upgrade-attempt.json` only if it matches the signed channel; delete `sealed.json`. Make the one atomic write. Remove the pending file. Start the Volume plugin again. - [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) becomes that one atomic write, called only from boot. It activates the record and retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named in `kept_keys`, with a Cleared tombstone for each retired Device key. -- [ ] Add a test in `core/crates/ployzd/src/` that lists every file the daemon writes to its data directory and fails on any file with no boot rule for a pending activation. +- [ ] Add a test in `core/crates/ployzd/src/` that lists every file the daemon writes to its data directory, every key of `restart-approvals.json`, and every table in `machine.db`'s schema (`docker/spec_store.rs:22`). It fails on any that has no row in "Durable state on boot". +- [ ] Edit `core/crates/ployzd/src/machine/local_machine/ingress_doors.rs` (`restore_doors`, `:448`) to apply F3's pinned-image check to each held door, removing one with another image instead of starting it. Edit `core/crates/ployzd/src/machine/local_machine/private_access.rs` (`restore_private_access`, `:327`) to always rebuild the router spec with the pinned image, never reusing a stored image. - [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. - [ ] Edit `core/crates/ployz/src/handlers/status_servers.rs` so `ployz status` over the local socket prints this Machine's key fingerprint. It prints none today, and the fingerprint pin source compares against it. @@ -490,9 +514,13 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A crash after the commit leaves the record activated, with the slots retired and the pending file gone or ignored. - [ ] `ployzd` test `boot_reverifies_durable_state`. - A `join-approvals.json` entry signed by the planted key is dropped at the commit, and one signed by a kept key stays. + - A cached join reply in `restart-approvals.json` whose stored approval the planted key signed is dropped with its Sealing material. A resend of that `Register` or `ShareSealingKey` after the commit takes the uncached path, is refused under enforcement, and publishes no joiner. One signed by a kept key stays and still answers its resend. + - An `AcceptedBuild` recorded under a rule the planted key signed is dropped. The next creation of that build is refused without a fresh GitHub token. Under a rule a kept key signed, the next creation also needs a token, and passes with one. + - A stored Private Access router spec, sealed key and Cluster network restore to the same tailnet and the same advertised network. A stored spec carrying another image is rebuilt with the pinned image. Nothing is added: no new host, key, peer or route. + - A stored door whose image is not the daemon's pin is removed, not started. - An unstarted `upgrade-attempt.json` naming a release off the signed channel is dropped. - The Volume plugin unit is stopped before the commit and started after. - - The data-directory file list test passes. + - The inventory test passes over files, `restart-approvals.json` keys and `machine.db` tables. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. - [ ] `ployzd` test `a_mesh_caller_is_never_full_on_an_enforced_v1_daemon`. With enforcement off, a mesh `SetDevice` is refused at dispatch. - [ ] `ployzd` tests for rotation. @@ -534,6 +562,7 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - The harness watches web-2's journal for the activation commit line. After it sees the line, it sends a fresh nonce through every stream it still holds: the withheld Config and Dockerfile, shell input, and a resend of the create. It also calls `GET /ployz/sealed` and pushes with the grant. Pass when no file or Container carrying a post-commit nonce exists on web-2, the inspect returned no plaintext, both grant calls are refused, and no sealed plaintext appears in the holder's output. Commands the shell received before the commit may still finish, so they don't count. - The harness key then dials web-1, web-2 and web-3 afresh and calls `Exec`, `WrapSealingKey` and `SetDevice`. Each call is refused, and each dial is told the key was cleared. Save `f6-planted-device.png`. Cloud's ordinary Operator work on web-2 succeeds after it reconnects. - Re-adding a legitimate retired Device with `ployz device request` and `ployz device approve` restores its Full scope. + - The cached-join resend. Before activation, the harness Device approves a join of a second harness Machine through web-3, and web-3 caches the reply. After the commit, the harness resends that `Register` and `ShareSealingKey` to web-3. Save `f6-cached-join.png`. Pass when both are refused, no Member row for the second harness Machine appears on any Server, and it receives no Sealing material. - Record how long each Server's management API was down during activation, and that Containers and routes served throughout. - [ ] Lane 6. Leave one Server on 0.3 and activate. Save `f6-old-server.png`. Pass when it is named and left out, and the rest activate. - [ ] Lane 7. Attacker harness. `SetDeviceEnforcement { enforced: false }` from a Device after activation. Save `f6-no-off.png`. Pass when it is refused. @@ -609,6 +638,7 @@ Outcome. Enforcement is the only mode. A fresh daemon refuses a join that carrie - The Device's known-Machines file, recorded at `ApproveJoin`. - Retiring every Device slot and trusted passkey outside the snapshot, in the same write that records the activated set, with `kept_keys` on `StartingSet`. And `management_scope` and statement verification reading only snapshot keys once the record is activated. - Activation as a process boundary. `SetDeviceEnforcement` writes a pending activation and restarts the daemon. The boot commit re-verifies `join-approvals.json` against the snapshot's keys, then retires slots and activates in one write before serving. A agrees that `JoinApproval` records keep their signer, so they can be re-verified, and that no durable record of A's stores an accepted effect without its signed statement. One key may keep both a Device slot and an Operator slot, because after the restart each connection proves its key against the activated record. + - Signers on cached authority. A cached join reply already keeps its `Signed`, and A keeps it that way. `AcceptedBuild` records no signer, so the commit drops every one. Thread D owns rules, so D records the rule's signer key on `AcceptedBuild` from now on, and a later boot rule can keep those whose signer is in the snapshot. A also confirms that ending Full scope for mesh callers at F6's first start breaks no forwarded call that needs it. Today a Device's Full call forwarded between Machines carries no Operator header (`ployzd/src/machine_api/mod.rs:296`). 8. **Sealing Key rotation at activation (thread A).** F6 rotates the Cluster's Sealing Key to the approved set, as described in "Activation". A owns the Sealing Key's statements, so A agrees to four things: one rotation per activation operation ID, sealing the new key only to Members of the signed set, keeping the old key for opening stored values only, and re-sealing only to the key the activation names. A also agrees how the new public key replaces the old one in the Cluster record. The recovery backup is re-wrapped by the CLI's new code, as `ployz recovery rotate` does today. @@ -637,7 +667,7 @@ Read on `main` at `02aaa926b5`, not run. - **Read.** `SetIngressDoors` and `SetPrivateAccess` are refused under enforcement by `admit_operator_deploy` (`machine_api/local.rs:1167`, `:1179`). `PruneImages` and `PublishCertificateMaterial` are not gated. - **Read.** `ployzd/src/machine_api/mod.rs:185` gives a mesh caller Full scope when enforcement is off. F7 removes that branch with the switch. - **Read.** With enforcement off, a non-local mesh caller gets Full scope (`ployzd/src/machine_api/mod.rs:185`), so a Machine Cloud enrolled can call `SetDevice`, `SetDeviceEnforcement` and `Exec` on every other Machine. Under enforcement off, `ShareSealingKey` needs no approval. No route rotates the Sealing Key today, and `ployz recovery rotate` re-wraps the existing key only. -- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), and the watcher sees the change later (`ployzd/src/management/mod.rs:430`). A call's scope is fixed in its headers on arrival (`ployzd/src/machine_api/mod.rs:263` to `:303`), and mutation admission checks only that the key is still accepted (`ployzd/src/machine/local_machine.rs:396`) before spawning work detached from the RPC (`:438` to `:454`). A Build Grant records only `Minter::Full` (`ployzd/src/management/build_grant.rs:74` to `:92`), enforcement refuses only Operator-minted grants (`:364`), the grant ALPN skips revocation (`ployzd/src/management/mod.rs:259`), and the holder reads opened values at `/ployz/sealed` (`build_grant.rs:449`). The tunnel checks Full scope once, at open (`ployzd/src/management/tunnel.rs:49`). `Exec` waits for its Config and then creates and starts the exec with no admission (`ployzd/src/docker/stream.rs:33`, `:63`, `:69`; `machine_api/local.rs:736`). A raw `Build` takes installation exclusion only and runs files it receives later (`ployzd/src/build.rs:194`, `:357`, `:432`). `InspectContainer` redacts env only for a scope captured as Operator (`machine_api/local.rs:537` to `:552`). `restart_approval.rs:134` to `:139` and `deploy_approval.rs:54` to `:73` hand on an accepted want without its signer. These are why F6 makes activation a process boundary. The durable files in the data directory are listed in "Activation". Of them, only `join-approvals.json` and `upgrade-attempt.json` can carry authority across a restart, and both can be re-verified. The Volume plugin runs as its own unit (`ployzd/src/installer/host.rs:355`), and `ployzd` restarts under `Restart=always` with `RestartSec=2` (`:284`). +- **Read.** `management_scope` gives a key the widest scope of any slot that holds it, with no other check (`ployzd/src/machine/mod.rs:440`). `set_device_enforcement` clears and refills `trusted_machines` and leaves `management_clients` untouched (`ployzd/src/machine/store.rs:902`). A record change closes a key's live connections with `REVOKED` only when no slot admits it any more (`ployzd/src/management/mod.rs:428`, `ployzd/src/machine/mod.rs:452`). `approve_device` accepts a key that already holds another slot (`ployzd/src/machine/store.rs:791`). An `Exec` stream's stdin loop makes no further scope check once it starts (`ployzd/src/docker/stream.rs:95`). The record owner saves and publishes in one mailbox turn (`ployzd/src/machine/record_owner.rs:229`), and the watcher sees the change later (`ployzd/src/management/mod.rs:430`). A call's scope is fixed in its headers on arrival (`ployzd/src/machine_api/mod.rs:263` to `:303`), and mutation admission checks only that the key is still accepted (`ployzd/src/machine/local_machine.rs:396`) before spawning work detached from the RPC (`:438` to `:454`). A Build Grant records only `Minter::Full` (`ployzd/src/management/build_grant.rs:74` to `:92`), enforcement refuses only Operator-minted grants (`:364`), the grant ALPN skips revocation (`ployzd/src/management/mod.rs:259`), and the holder reads opened values at `/ployz/sealed` (`build_grant.rs:449`). The tunnel checks Full scope once, at open (`ployzd/src/management/tunnel.rs:49`). `Exec` waits for its Config and then creates and starts the exec with no admission (`ployzd/src/docker/stream.rs:33`, `:63`, `:69`; `machine_api/local.rs:736`). A raw `Build` takes installation exclusion only and runs files it receives later (`ployzd/src/build.rs:194`, `:357`, `:432`). `InspectContainer` redacts env only for a scope captured as Operator (`machine_api/local.rs:537` to `:552`). `restart_approval.rs:134` to `:139` and `deploy_approval.rs:54` to `:73` hand on an accepted want without its signer. These are why F6 makes activation a process boundary. The durable files in the data directory are listed in "Activation". Read again on `main` at `08c00fe2f8`. Four kinds of state can carry authority across a restart: `join-approvals.json`, the join replies cached in `restart-approvals.json` (`restart_approval.rs:332`, used at `local_machine.rs:927` and `sealing.rs:179` before any re-verification), the `AcceptedBuild` entries (`restart_approval.rs:239`, `:293`, used at `rule_deploy.rs:100`), and `upgrade-attempt.json`. Each is re-verified or dropped at the commit. `machine.db`'s `containers` table holds specs without approvals (`docker/spec_store.rs:423`), which `restore_private_access` (`daemon.rs:532`) and `restore_doors` (`ingress_doors.rs:448` to `:462`) converge at boot. Doors converge to the held specs. The router reuses a stored spec or rebuilds one from the stored sealed key (`private_access.rs:327` to `:333`). The replicated `machines` table is filtered by `accepts_machine` (`corrosion/store.rs:731`). The Volume plugin runs as its own unit (`ployzd/src/installer/host.rs:355`), and `ployzd` restarts under `Restart=always` with `RestartSec=2` (`:284`). - **Read.** `ployz device enforce` gathers members through `server::connect_full`, which falls back to the connections in Cloud's Server Access response (`ployz/src/handlers/server.rs:142`). `sign_starting_set` signs whatever members answer (`ployz/src/handlers/device.rs:526`). No Machine checks the set against its own peers. Unproven until the slice that proves it: From 9d2ed1f68a0147b259623c0b32f692c2c6ab5923 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Tue, 10 Nov 2026 13:06:56 +0000 Subject: [PATCH 9/9] Add Private Access node state to the activation inventory The tailscale state directory is the durable word that a Machine wants a router, and restore reads it. It restores the same node on the same tailnet with the pinned image, and the inventory test now walks the data directory recursively. --- docs/agents/plans/security-0.4/f-plan.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/agents/plans/security-0.4/f-plan.md b/docs/agents/plans/security-0.4/f-plan.md index 1d731e937f..0c490285ed 100644 --- a/docs/agents/plans/security-0.4/f-plan.md +++ b/docs/agents/plans/security-0.4/f-plan.md @@ -127,13 +127,14 @@ Activation is today's `ployz device enforce` with five changes. It promotes noth | `config.toml` (`ployzd/src/corrosion/service.rs`) | The replicated store's own configuration | Keep. The daemon writes it, and it holds no key. | | `dns.json`, `bridge-stopped-members.json`, `bridge-member-addresses.json` | DNS and bridge state derived from the record and the store | Keep. Each is rebuilt from the activated record and the filtered `machines` table. | | `meta.json`, `env.json`, `first-set.json`, `salts.json` (`ployzd/src/observe/`), `state.json` (`ployzd/src/installer/prepare.rs`) | Observation and install state | Keep. Not authority. | + | `/tailscale/`, with `ployz-state-id` and the router's persisted node state (`ployzd/src/docker/private_access.rs:48` to `:68`), and its `.forgotten` sibling | Whether this Machine wants a Private Access router (`state_kept`, `:106` to `:108`): the state's ID, stamped on the router that mounts it, and the tailnet node the router registered. Restoration reads it (`ployzd/src/machine/local_machine/private_access.rs:299` to `:325`). | Restore under the Private Access rule below: the same node, on the same tailnet, on the pinned image. Join nothing new. Name the router and its tailnet in the activation output. A state directory that no router carries the ID of places nothing, since restore returns early with no router (`:296`). A leftover `.forgotten` directory is deleted, finishing the `off` that left it. The opened auth key is in memory under `/run`, so it is not durable, and restore writes it again from the sealed key. | | `machine.db` `containers` (`ployzd/src/docker/spec_store.rs:22`, written at `:423`) | Each Container's `ResolvedServiceSpec`, without its approval. Boot restores the Private Access router and the ingress doors from it. | Restore only, under the rule below. | | `machine.db` `volume_floors`, `rotations`, `stops`, `creations` | Copy floors, rotation and stop records for existing Containers, and creation slots | Keep. Each describes or bounds an existing Container. A new creation is admitted under enforcement like any other. | | Replicated `machines` | Members | Read through `accepts_machine` (`ployzd/src/corrosion/store.rs:731`), which under enforcement keeps only trusted Members. That is the activated set, so a left-out legacy Member is not peered. | | Replicated `cluster` | The Cluster's name and network | Keep. Private Access reads the network from the record first (`private_access.rs:203`). | | Replicated `ingress_routes`, `certificates`, `containers`, `volumes` | Routes, certificate material, observations and Volume placement that Caddy and status read | Keep. They are the `Bound` route and certificate data in f-inventory.md, and nothing at boot creates or starts anything from them. | - `saved-machine.json` appears only in a test (`management_client.rs:478`). F6's test lists every file in the data directory and every table in `machine.db`, and fails on any that has no row here. + `saved-machine.json` appears only in a test (`management_client.rs:478`). F6's test walks the data directory recursively and lists every table in `machine.db`. It fails on any file, subdirectory or table that has no row here. **Boot restoration stays within what was placed before.** On boot, `restore_private_access` (`daemon.rs:532`) and `restore_doors` (`ployzd/src/machine/local_machine/ingress_doors.rs:448` to `:462`) converge Containers from `machine.db`'s stored specs. They restore what was already placed before activation, so they belong with "What activation cannot undo". The boundary is that boot may only converge back to what is stored. Any change after activation needs a signed approval checked against the snapshot. I read both paths on `main`. - **Doors.** `restore_doors` opens the stored sealed tunnel token, writes it, and converges to `held.specs` from the Docker inventory (`:449` to `:462`). It adds no door, host or token. It does start a door that is at rest. Under enforcement off, Cloud could have set a door with an image of its choosing. So F6 adds F3's pinned-image check to `restore_doors`: a held door whose image is not the daemon's pin is removed, not started. Without that check, this path would start Cloud's code after activation. @@ -457,7 +458,7 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - [ ] Edit `restart_approval.rs` so an admitted set is not applied in place. It writes and syncs a pending activation file, refuses new management work with `Error::Activating` naming `ployz.machine.enforced.v1`, and calls `request_restart` (`ployzd/src/machine/record_owner.rs:239`). - [ ] Edit the daemon's boot path in `core/crates/ployzd/src/daemon.rs` so that, before it serves anything, a pending activation is finished in this order. Stop the Volume plugin unit. Apply every boot rule in "Activation", "Durable state on boot": re-verify `join-approvals.json` and the cached join replies in `restart-approvals.json` against the snapshot's keys, dropping what fails with its Sealing material; drop every `AcceptedBuild`; keep an unstarted `upgrade-attempt.json` only if it matches the signed channel; delete `sealed.json`. Make the one atomic write. Remove the pending file. Start the Volume plugin again. - [ ] Edit `core/crates/ployzd/src/machine/store.rs` so `set_device_enforcement` (`:902`) becomes that one atomic write, called only from boot. It activates the record and retires every Device slot and trusted passkey whose key isn't the signer, evidenced, or named in `kept_keys`, with a Cleared tombstone for each retired Device key. -- [ ] Add a test in `core/crates/ployzd/src/` that lists every file the daemon writes to its data directory, every key of `restart-approvals.json`, and every table in `machine.db`'s schema (`docker/spec_store.rs:22`). It fails on any that has no row in "Durable state on boot". +- [ ] Add a test in `core/crates/ployzd/src/` that walks the data directory recursively and lists every file and subdirectory the daemon writes there, every key of `restart-approvals.json`, and every table in `machine.db`'s schema (`docker/spec_store.rs:22`). It fails on any that has no row in "Durable state on boot". - [ ] Edit `core/crates/ployzd/src/machine/local_machine/ingress_doors.rs` (`restore_doors`, `:448`) to apply F3's pinned-image check to each held door, removing one with another image instead of starting it. Edit `core/crates/ployzd/src/machine/local_machine/private_access.rs` (`restore_private_access`, `:327`) to always rebuild the router spec with the pinned image, never reusing a stored image. - [ ] Edit `core/crates/ployzd/src/machine/mod.rs` so `management_scope` (`:440`) and the trusted-key lookups behind statement verification read only keys in the trusted snapshot once the record is activated. Edit `core/crates/ployz-core/src/authority/starting_set.rs` so `StartingSet` gains an optional `kept_keys` list, the keys the person pinned. - [ ] Edit `core/crates/ployzd/src/machine/local_machine/sealing.rs` to rotate the Sealing Key as activation's last step (gap 8). The lowest-ID activated Machine generates the key once per activation operation, seals it to each approved Member, and publishes the new public key. Each activated Machine keeps the old key only to open stored values, and re-seals them to the key the activation names. @@ -520,7 +521,8 @@ What users lose. A dashboard-only customer on a 0.3 Cluster needs the CLI, a Dev - A stored door whose image is not the daemon's pin is removed, not started. - An unstarted `upgrade-attempt.json` naming a release off the signed channel is dropped. - The Volume plugin unit is stopped before the commit and started after. - - The inventory test passes over files, `restart-approvals.json` keys and `machine.db` tables. + - A seeded `/tailscale/` with a `ployz-state-id`, node state and a router carrying that ID restores the same node on the same tailnet with the pinned image, and the activation output names the router and its tailnet. The same directory with no router carrying its ID places nothing. A seeded `.forgotten` directory is gone after the commit. + - The inventory test passes over every file and subdirectory in the data directory, walked recursively, plus `restart-approvals.json` keys and `machine.db` tables. A file seeded in a new subdirectory with no inventory row fails it. - [ ] `ployzd` test `a_starting_set_outside_the_snapshot_is_refused`. A set that adds a Member absent from the snapshot, or names a snapshot ID under another key, is refused over a Device connection, naming it, and the record is unchanged. A join with a verified approval after the snapshot extends it, and a join without one does not. - [ ] `ployzd` test `a_mesh_caller_is_never_full_on_an_enforced_v1_daemon`. With enforcement off, a mesh `SetDevice` is refused at dispatch. - [ ] `ployzd` tests for rotation.