carlos / platform Public

instance policy: plan 1 copy deck and rollout note

Collect plan 1's user-facing strings into
docs/superpowers/notes/copy-instance-policy-plan1.md for the one copy
review that covers plans 1 to 3 before merge, and add the README rollout
note (included in the deck for review).

A writer lock with no owner no longer reads "another console ()": the
refusal names the expiry alone (policyLeaseHeldNoOwnerMsg).

apps show --account-id uses the approved settings.cli.flag-account-id
help instead of a copied literal with an em dash.

Approved-copy records are untouched until the review, so
TestZeroDowntimeCopyIsApplied stays red on zdd.apps.usage-line and
zdd.apps-set.nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3609057bec3b6f7ece06d5964c19c6b1c2044de8 parent 87fe7f5
5 files changed, +307 −2
  • README.md +11 −0
  • apps_policy.go +2 −1
  • docs/superpowers/notes/copy-instance-policy-plan1.md +264 −0
  • internal/console/policylease.go +9 −1
  • internal/console/policylease_test.go +21 −0
diff --git a/README.md b/README.md
index 3cf801394..fcdf6bafe 100644
--- a/README.md
+++ b/README.md
@@ -512,6 +512,17 @@ Roll it in this order:
6. apply IAM step 2. condition the role's `iam:CreateUser` and `iam:PutUserPolicy` on `iam:PermissionsBoundary` equal to the boundary ARN. do this only after every console runs this release, because an older console creates users without the boundary and step 2 refuses them.
7. wait a few minutes for IAM to propagate, then run the check in step 5 again with your own operator credentials, not the console role. `iam:CreateAccessKey` cannot be conditioned on the boundary, so anyone holding the console role could have made an unbounded user between step 5 and step 2. delete any user without the boundary, or set the boundary on it. the gap is closed only when this second check is clean.
+### App policies: egress allows and sizes (2026-10-25)
+
+This release adds the app policy (#328, #329). A system operator can let an app's instances reach named private addresses with `carlos apps set --egress-allow`, and choose the size they run at with `carlos apps set --size` and `carlos instances set --size`. `carlos apps show` reads it back. Each app's policy is one signed object in the deployment bucket, under `control/app-policy/`.
+
+- with nothing granted, this release changes nothing. there is no policy until an operator sets one, and every box runs its instances as it does today.
+- roll boxes first, then consoles, and dev first, as always.
+- every console that writes the deployment bucket must run this release before anyone grants the first policy. an older console leaves the policy in place when it purges an app or creates one, so a new app with an old app's name could inherit its egress allow and size.
+- one console per bucket writes policies. it holds a lease at `control/app-policy-lease/lock.json`, taken the first time it creates an app or changes a policy, and renewed every five minutes after that. a second console on the same bucket refuses both, and names the console that holds the lease and when it expires.
+- a console that stops cleanly gives the lease back. if it crashes, or its role may not delete objects in the bucket, the lease stays held for up to 15 minutes. until it lapses, the console that replaced it refuses app creates and policy changes.
+- to roll a console back below this release once any policy exists, first delete everything under `control/app-policy/` in the deployment bucket. that removes every egress allow and size, so note them first and set them again once the console is back on this release.
+
## App config & secrets
Per-app config lives in the deployment bucket
diff --git a/apps_policy.go b/apps_policy.go
index 7aca0c5b7..b47f8037d 100644
--- a/apps_policy.go
+++ b/apps_policy.go
@@ -26,6 +26,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/egressaddr"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/updatewords"
)
// Copy (collected for review in plan Task 15).
@@ -270,7 +271,7 @@ func runAppsShow(args []string) {
lead, args := leadingArg(args)
fs := flag.NewFlagSet("apps show", flag.ExitOnError)
acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
- acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ acctIDRaw := fs.String("account-id", "", updatewords.Text("settings.cli.flag-account-id"))
consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
fs.Usage = flagUsage(fs, appsShowUsage)
fs.Parse(args)
diff --git a/docs/superpowers/notes/copy-instance-policy-plan1.md b/docs/superpowers/notes/copy-instance-policy-plan1.md
new file mode 100644
index 000000000..071097dc8
--- /dev/null
+++ b/docs/superpowers/notes/copy-instance-policy-plan1.md
@@ -0,0 +1,264 @@
+# Instance sizes and egress allows, plan 1: copy deck (pending review)
+
+Every user-facing string plan 1 (`2026-10-21-instance-policy-1-foundation`)
+added or changed, for the one copy review that covers plans 1 to 3 before
+the branch merges (controller ruling, Task 15). Nothing here is approved
+yet. After the review, the approved text goes into the code verbatim and
+`approved_instance_policy_copy_test.go` pins it, in the shape of
+`approved_run_copy_test.go`.
+
+Collected from the Task 1 to 12 reports and from `git diff 87a580be..HEAD`
+(non-test files). Columns:
+
+- **id**: this deck's id, for the review and the approved-copy test.
+- **where**: the Go constant or call site, `file:line` at the Task 15 commit.
+- **text**: the current text, exactly. `%s`, `%q`, `%d` and `%v` are filled
+ at run time (`%q` adds double quotes).
+- **seen**: where a person reads it.
+- **base**: how many non-test Go files held this exact text at the base
+ commit `87a580be` (memory: a shared string needs its own review, and a
+ rewrite must not hit its other uses). `0` means new in plan 1.
+
+How a door message reaches a person: the CLI prints the console's JSON
+`error` as it is. `apps set` puts `apps set: ` in front, `instances set`
+puts `instances set: `, `apps show` prints it bare. A door message is also
+what an API caller reads in the response body.
+
+## 1. Egress address refusals (Task 1, `internal/egressaddr`)
+
+Seen in two places: at the console door (`PUT .../policy`, the 400 body), and
+client-side before any request as `apps set: --egress-allow: <text>`
+(IP1-31). Plan 2's root report shows them again on the box.
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-01 | internal/egressaddr/egressaddr.go:36 `msgEmpty` | an address is empty | door 400; `apps set` | 0 |
+| IP1-02 | internal/egressaddr/egressaddr.go:37 `msgRange` | %q is a range. Allow single addresses only | door 400; `apps set` | 0 |
+| IP1-03 | internal/egressaddr/egressaddr.go:38 `msgPort` | %q has a port. An allow opens an address, never a port | door 400; `apps set` | 0 |
+| IP1-04 | internal/egressaddr/egressaddr.go:39 `msgZone` | %q has a zone. Name the address without it | door 400; `apps set` | 0 |
+| IP1-05 | internal/egressaddr/egressaddr.go:40 `msgNotAddr` | %q is not an IP address | door 400; `apps set` | 1 (the fragment "is not an IP address" is also in internal/relay/serve.go, a separate literal) |
+| IP1-06 | internal/egressaddr/egressaddr.go:41 `msgLoopback` | %s is a loopback address | door 400; `apps set` | 0 |
+| IP1-07 | internal/egressaddr/egressaddr.go:42 `msgUnspecified` | %s is the unspecified address | door 400; `apps set` | 0 |
+| IP1-08 | internal/egressaddr/egressaddr.go:43 `msgMetadata` | %s is the cloud metadata service | door 400; `apps set` | 0 |
+| IP1-09 | internal/egressaddr/egressaddr.go:44 `msgLinkLocal` | %s is link-local | door 400; `apps set` | 2 (the fragment also appears in internal/provision/provision.go, a separate literal) |
+| IP1-10 | internal/egressaddr/egressaddr.go:45 `msgMulticast` | %s is a multicast address | door 400; `apps set` | 0 |
+| IP1-11 | internal/egressaddr/egressaddr.go:46 `msgReserved` | %s is in %s, a reserved range | door 400; `apps set` | 0 |
+| IP1-12 | internal/egressaddr/egressaddr.go:47 `msgNAT64` | %s is in the NAT64 prefix 64:ff9b::/96, which reaches any IPv4 address | door 400; `apps set` | 0 |
+| IP1-13 | internal/egressaddr/egressaddr.go:48 `msgPublic` | %s is a public address and is already reachable. No allow is needed | door 400; `apps set` | 0 |
+| IP1-14 | internal/egressaddr/egressaddr.go:49 `msgTooMany` | at most %d addresses per app (%d given) | door 400; `apps set` | 0 |
+
+## 2. The policy writer lease (Task 8, `internal/console/policylease.go`)
+
+Since Task 11 these also answer an app create, so they reach more people
+than policy writers: `carlos apps create`, a temporary site create (CLI
+body), and the console's New app form, where the text is the flash as it
+is (lower case first letter, no full stop, unlike the other create
+flashes).
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-15 | internal/console/policylease.go:80 `policyLeaseHeldMsg` | another console (%s) is the policy writer for this deployment until %s. Make the change there, or try again after that | 503 on a policy change or an app create while another console holds the lease; also the browser create flash. The first `%s` is the lock's owner, `<hostname>:<pid>`; the second is the expiry in UTC, RFC 3339 | 0 |
+| IP1-16 | internal/console/policylease.go:81 `policyLeaseHeldNoOwnerMsg` (new in Task 15) | another console is the policy writer for this deployment until %s. Make the change there, or try again after that | the same, when the lock names no owner. Proposed fallback, see "Task 8 item" below | 0 |
+| IP1-17 | internal/console/policylease.go:82 `policyLeaseUnavailableMsg` | could not take the policy writer lease. Try again | 503, same places; also while the console is shutting down | 0 |
+| IP1-18 | internal/console/policylease.go:83 `policyLeaseMissingMsg` | this console is not set up to write app policies (no writer lease). Deploy a console built from current main | 503, same places | 0 |
+| IP1-19 | internal/console/policylease.go:84 `policyLeaseLostMsg` | this console stopped being the policy writer during the change, so it may not have been published. Read the policy again before you retry | 503 on a policy change or create whose lease lapsed mid-write | 0 |
+
+### Task 8 item: a lock with no owner
+
+`policyLeaseHeldMsg` reads `another console () is the policy writer ...`
+when the lock carries an empty owner. litestream always writes
+`<hostname>:<pid>`, so it should not happen, but a hand-written or damaged
+lock could. Proposed fallback (IP1-16): drop the parenthesis and keep the
+rest word for word. Implemented in Task 15: `policyWriter` uses
+`policyLeaseHeldNoOwnerMsg` when the owner is empty or only spaces
+(`internal/console/policylease.go:210`), tested by
+`TestPolicyWriterHolderWithNoOwner`.
+
+## 3. The policy door (Task 9, `internal/console/apipolicy.go`)
+
+`PUT /api/cli/apps/{account}/{app}/policy` and its `GET`. System operators
+only. The CLI checks addresses, sizes and preconditions before it sends, so
+the rows marked "API only" are what a direct API caller reads; a CLI user
+would only see them if the CLI and console disagreed.
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-20 | internal/console/apipolicy.go:64 `policyNothingMsg` | nothing to set: send egress_allow, default_size or size | 400, API only (the CLI never sends an empty change) | 0 |
+| IP1-21 | internal/console/apipolicy.go:65 `policyDefaultSizeMsg` | default_size must be small, standard, large or xlarge, or empty for the build's size | 400, API only (`apps set --size` checks first, IP1-32) | 0 |
+| IP1-22 | internal/console/apipolicy.go:66 `policySizeMsg` | size must be small, standard, large, xlarge or default | 400, API only (`instances set --size` checks first, IP1-33) | 0 |
+| IP1-23 | internal/console/apipolicy.go:67 `policyNoInstanceMsg` | no instance %s in this app | 404, `instances set --size` on a host the app does not have | 0 |
+| IP1-24 | internal/console/apipolicy.go:68 `policyPreconditionMsg` | send If-Match with the policy's etag, or If-None-Match: * when there is none | 428, API only (the CLI always sends one) | 0 |
+| IP1-25 | internal/console/apipolicy.go:69 `policyChangedMsg` | the policy changed since it was read. Read it again and retry | 412 body, API only; the CLI replaces it with IP1-35 | 0 |
+| IP1-26 | internal/console/apipolicy.go:70 `policyRecordFailedMsg` | the change could not be recorded, so it was not made. Try again | 503 on `apps set` / `instances set` | 0 |
+| IP1-27 | internal/console/apipolicy.go:71 `policyAuditFailedMsg` | the change could not be audited, so it was not made. Try again | 503 on `apps set` / `instances set` when the audit row cannot be written (Astra plan R8-3) | 0 |
+| IP1-28 | internal/console/apipolicy.go:72 `policyUnpublishedMsg` | the change is recorded but not yet published. Run the command again to publish it now | 502 on `apps set` / `instances set` | 0 |
+| IP1-29 | internal/console/apipolicy.go:73 `policyReadFailedMsg` | could not read the policy. Try again | 502 or 503 on `apps show`, `apps set`, `instances set` | 0 |
+| IP1-30 | internal/console/apipolicy.go:74 `policyInvalidHostMsg` | invalid host | 400 on `instances set --size` with a malformed host | 15 (shared wording across ten files; this is its own literal, so a rewrite here changes only this door) |
+
+Reused unchanged, not new copy: `doorKeyNoBucketMsg`
+(`internal/console/apidoorkeys.go:44`, "no deployment bucket on this host.
+the console is running without one") answers the `GET` on a console with no
+bucket.
+
+### Audit detail (activity tab)
+
+Built by `policyAuditDetail` (`internal/console/apipolicy.go:1081`), action
+`app-policy`. One part per field the request changed, `old -> new`, joined
+with `; `, then ` (cli)`. A request that changed nothing lists every part it
+sent. Example: `egress allow: none -> 10.0.5.20; default size: the build's
+size -> large (cli)`.
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-A1 | internal/console/apipolicy.go:1103 | egress allow: %s -> %s | activity tab | 0 |
+| IP1-A2 | internal/console/apipolicy.go:1107 | default size: %s -> %s | activity tab | 0 |
+| IP1-A3 | internal/console/apipolicy.go:1118 | size of %s: %s -> %s (the host, then old and new) | activity tab | 0 for the part; "size of " alone is a common phrase (12 files) |
+| IP1-A4 | internal/console/apipolicy.go:1091 | none | activity tab, an empty allow | "none" is a common literal (107); this one is inline in the closure |
+| IP1-A5 | internal/console/apipolicy.go:1097 | the build's size | activity tab, no default size | 0 at base; now 5 uses, see "One term, five places" |
+| IP1-A6 | internal/console/apipolicy.go:1115 | the app default | activity tab, no override for the host | 13 (config uses the same words elsewhere, separate literals) |
+| IP1-A7 | internal/console/apipolicy.go:1124 | ` (cli)` suffix | activity tab | 93, the existing convention, unchanged |
+
+## 4. App create (Task 11, `internal/console/server.go`)
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-40 | internal/console/server.go:4159 `appCreatePolicyClearMsg` | an earlier app's settings under this name could not be cleared, so nothing was created. Try again, or ask an operator to delete %s from the deployment bucket | 503 on `carlos apps create` and a temporary site create (`apicli.go:486`, `apitemporary.go:221`); `%s` is the key, `control/app-policy/<sqid>/<app>.json` | 0 |
+| IP1-41 | internal/console/server.go:4160 `appCreatePolicyClearFlash` | Could not clear an earlier app's settings under this name, so nothing was created. Please try again. | the New app form's flash (`server.go:4561`) | 0 |
+
+The lease refusals (IP1-15 to IP1-19) also answer a create:
+`apicli.go:487`, `apitemporary.go:223`, and the flash at `server.go:4562`.
+
+## 5. The CLI (Task 12)
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-31 | apps_policy.go:39 `appsSetBadEgress` | apps set: --egress-allow: %v | `apps set`, before any request; `%v` is an IP1-01 to IP1-14 text | 0 |
+| IP1-32 | apps_policy.go:40 `appsSetBadSize` | apps set: --size is small, standard, large, xlarge or default, not %q | `apps set` | 0 |
+| IP1-33 | apps_policy.go:41 `instancesSetBadSize` | instances set: --size is small, standard, large, xlarge or default, not %q | `instances set` | 0 |
+| IP1-34 | apps_policy.go:42 `appsPolicyOldConsole` | not found: either %s is not an app in this account, you are not a system operator, or this console predates app policies (deploy a console built from current main) | `apps set`, `instances set`, `apps show` on a bare 404 | 0 (same shape as the approved `zdd.instances-retry.old-console`) |
+| IP1-35 | apps_policy.go:43 `appsPolicyChanged` | the policy changed while this ran. Run it again | a 412: someone changed the policy between the CLI's read and write | 0 |
+| IP1-36 | apps_policy.go:44 `appsSetEgressDone` | %s instances may reach %s | `apps set --egress-allow`, success; app, then the addresses joined with ", " | 0 |
+| IP1-37 | apps_policy.go:45 `appsSetEgressCleared` | %s instances have no egress allow | `apps set --egress-allow ""` | 0 |
+| IP1-38 | apps_policy.go:46 `appsSetSizeDone` | %s instances run at %s unless an instance has its own size | `apps set --size <size>` | 0 |
+| IP1-39 | apps_policy.go:47 `appsSetSizeCleared` | %s instances run at the build's size unless an instance has its own size | `apps set --size default` | 0 |
+| IP1-42 | apps_policy.go:48 `instancesSetSizeDone` | %s runs at %s | `instances set --size <size>`; host, then size | 0 |
+| IP1-43 | apps_policy.go:49 `instancesSetSizeDefault` | %s follows the app's size | `instances set --size default` | 0 |
+| IP1-44 | apps_policy.go:57 `appsShowDirectMode` | apps show works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url> | `apps show` in direct mode | 0 (same shape as the approved `zdd.apps-set.direct-mode`, which `apps set --egress-allow/--size` also shows) |
+
+### Help and usage
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-50 | apps_policy.go:34 `appsSetFlagEgress` | private addresses every instance of the app may reach, comma-separated, never a port. "" clears them. System operators only | `carlos apps set -h` | 0 |
+| IP1-51 | apps_policy.go:35 `appsSetFlagSize` | the size the app's instances run at: small, standard, large or xlarge. default goes back to the build's size. System operators only | `carlos apps set -h` | 0 |
+| IP1-52 | apps_policy.go:36 `instancesSetFlagSize` | this instance's size: small, standard, large or xlarge. default follows the app's size. System operators only | `carlos instances set -h` | 0 |
+| IP1-53 | apps_policy.go:37 `appsSetPolicyUsage` | Only system operators may pass --egress-allow or --size. A change reaches<br>the app's boxes within seconds once they run a release that applies it. | `carlos apps set -h`, the last paragraph before the flag list | 0 |
+| IP1-54 | apps_policy.go:50 `appsShowUsage` | usage: carlos apps show <app> [--account <sqid\|name>] [--account-id <sqid>] [--console <origin>] | `carlos apps show -h`, and a bad call | 0 |
+| IP1-55 | apps.go:89 (in `appsUsage`) | ` show <app> the app's egress allow and sizes. System operators only.` | `carlos apps -h` | 0 |
+| IP1-56 | apps.go:37 (first line of `appsUsage`; approved as `zdd.apps.usage-line`) | usage: carlos apps <create\|place\|build-probe\|set\|show\|delete\|restore\|ttl\|keep\|list> [flags] <app> | `carlos apps -h` | 0. Changed from the approved `usage: carlos apps <create\|place\|build-probe\|set\|delete\|restore\|ttl\|keep\|list> [flags] <app>` (1 at base): `show` added |
+| IP1-57 | apps_swap.go:41 `appsSetNothing` (approved as `zdd.apps-set.nothing`) | apps set: nothing to set. Pass --swap, --swap-concurrency, --egress-allow or --size | `apps set` with no flags | 0. Changed from the approved `apps set: nothing to set. Pass --swap, --swap-concurrency, or both` (1 at base) |
+
+`apps show` output (`appsShowRun`, apps_policy.go:307). Two lines, then
+one per instance with its own size:
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-60 | apps_policy.go:51 `appsShowEgress` | `egress allow %s` | `apps show` | 0 |
+| IP1-61 | apps_policy.go:52 `appsShowDefault` | `default size %s` | `apps show` | 0 |
+| IP1-62 | apps_policy.go:53 `appsShowOverride` | `size %s %s` (host, then size) | `apps show`, one per instance with its own size | 0 |
+| IP1-63 | apps_policy.go:54 `appsShowNone` | none | `apps show`, no allow | (see IP1-A4) |
+| IP1-64 | apps_policy.go:55 `appsShowBuildSize` | the build's size | `apps show`, no default size | (see IP1-A5) |
+| IP1-65 | apps_policy.go:56 `appsShowStale` | ` (not in force. The instance was deleted or recreated)` | appended to an override line | 0 |
+
+Example:
+
+ egress allow 10.0.5.20, fd00:5::20
+ default size large
+ size shop-a.example.com xlarge
+ size shop-b.example.com small (not in force. The instance was deleted or recreated)
+
+Reused unchanged, not new copy: `appsSetError` (`apps set: %v`), the
+`--account` and `--console` flag help, and the approved
+`settings.cli.flag-account-id` for `apps show --account-id`. Task 15
+switched `apps show --account-id` to that approved text: it had copied the
+older literal `account sqid, matched as a sqid ONLY — supersedes --account`,
+which carries an em dash (41 other uses at base, left alone).
+
+## 6. Log lines an operator acts on
+
+The constraints call log lines "not copy"; the controller's Task 15 ruling
+includes the ones an operator acts on. These are the ones plan 1 added that
+tell an operator something to do or check. The other new log lines are
+diagnostics and are left out.
+
+| id | where | text | seen | base |
+|---|---|---|---|---|
+| IP1-L1 | internal/release/trash.go:310 | release: purge %s/%s: policy object %s not deleted (%v); the purge retries until it can be removed | console log, a purge whose policy delete is refused (the flagship's `s3:DeleteObject` Deny) | 0 |
+| IP1-L2 | internal/console/apipolicy.go:894 | console: app policy %s/%s: not re-signing an object that does not verify; an operator write repairs it | console log, the re-sign pass | 0 |
+| IP1-L3 | internal/console/apipolicy.go:713 | console: app policies: not re-signing; %s | console log; `%s` is a lease refusal, IP1-15 to IP1-19 | 0 |
+| IP1-L4 | internal/console/apipolicy.go:719 | console: app policies: stopped re-signing at %s/%s; the writer lease was lost | console log | 0 |
+| IP1-L5 | internal/provision/apppolicy_reconcile.go:186 | provision: %s/%s: policy %s: %v (`%s` is `unreadable`, `unverifiable` or `expired`) | box log, once per app per pass | 0 |
+| IP1-L6 | internal/provision/apppolicy_reconcile.go:175 | provision: %s/%s: policy read failed: %v | box log | 0 |
+| IP1-L7 | internal/provision/apppolicy_cache.go:253 | provision: app policies: listing failed, retried next pass: %v | box log | 0 |
+
+Status words, set by plan 1 for plan 2's report and not yet shown to anyone:
+`policy unreadable`, `policy unverifiable`, `policy expired`
+(`internal/provision/apppolicy_reconcile.go:56`, `:187`).
+
+## 7. The README rollout note (Task 15)
+
+In `README.md` under "Releasing the platform", after the 2026-10-23 entry.
+Drafted for this review; the text in the README is exactly this:
+
+> ### App policies: egress allows and sizes (2026-10-25)
+>
+> This release adds the app policy (#328, #329). A system operator can let an app's instances reach named private addresses with `carlos apps set --egress-allow`, and choose the size they run at with `carlos apps set --size` and `carlos instances set --size`. `carlos apps show` reads it back. Each app's policy is one signed object in the deployment bucket, under `control/app-policy/`.
+>
+> - with nothing granted, this release changes nothing. there is no policy until an operator sets one, and every box runs its instances as it does today.
+> - roll boxes first, then consoles, and dev first, as always.
+> - every console that writes the deployment bucket must run this release before anyone grants the first policy. an older console leaves the policy in place when it purges an app or creates one, so a new app with an old app's name could inherit its egress allow and size.
+> - one console per bucket writes policies. it holds a lease at `control/app-policy-lease/lock.json`, taken the first time it creates an app or changes a policy, and renewed every five minutes after that. a second console on the same bucket refuses both, and names the console that holds the lease and when it expires.
+> - a console that stops cleanly gives the lease back. if it crashes, or its role may not delete objects in the bucket, the lease stays held for up to 15 minutes. until it lapses, the console that replaced it refuses app creates and policy changes.
+> - to roll a console back below this release once any policy exists, first delete everything under `control/app-policy/` in the deployment bucket. that removes every egress allow and size, so note them first and set them again once the console is back on this release.
+
+Plans 2 and 3 add their own lines (the fleet-address check and boxes
+before egress grants, root's report, sizes reaching running instances)
+before the review.
+
+## 8. For the reviewer
+
+- **One term, five places.** "the build's size" is in IP1-21, IP1-39,
+ IP1-51, IP1-64 and IP1-A5. Change it in all five or none.
+- **Lease messages on a create.** IP1-15 says "Make the change there". On
+ an app create the "change" is the create. Keep it general, or add a
+ create wording?
+- **Create flash casing.** On the New app form the lease refusals show as
+ a flash in door style (lower case, no full stop), beside flashes like
+ IP1-41 ("Could not ... Please try again."). A flash form for each lease
+ message would need new constants.
+- **"settings" in IP1-40 and IP1-41.** They say "an earlier app's
+ settings" for the policy object (its egress allow and sizes), so as not
+ to say "policy" to an app member. Is "settings" right, given app config
+ and update settings also exist?
+- **Approved strings left as they were (gaps, not changed).**
+ - `instances set` usage (`settings.cli.set-usage`) does not list
+ `--size`, and ends "(an empty value clears)", which is not true of
+ `--size` (an empty value is refused; `default` clears). A possible
+ line: `usage: carlos instances set <host> [--app <a>] [--updates
+ auto|manual] [--channel <c>] [--major <n>] [--pin <build>] [--size
+ <size>|default] (an empty value clears, except --size: default
+ clears it)`.
+ - `apps set` usage (`zdd.apps-set.usage`) does not list
+ `--egress-allow` or `--size` in its synopsis line; they appear only in
+ the flag list and IP1-53.
+ - `apps set --app` help (`zdd.apps-set.flag-app`, "app whose swap
+ policy to set") is now narrower than the command.
+
+## 9. Approved-copy records
+
+Not edited in Task 15 (controller ruling: approved records change only
+after the review). `TestZeroDowntimeCopyIsApplied` stays red on
+`zdd.apps.usage-line` (IP1-56) and `zdd.apps-set.nothing` (IP1-57) until
+the review approves their new text in
+`docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json`.
+`approved_instance_policy_copy_test.go` is written after the review.
diff --git a/internal/console/policylease.go b/internal/console/policylease.go
index bb5f1a49f..6393c9617 100644
--- a/internal/console/policylease.go
+++ b/internal/console/policylease.go
@@ -58,6 +58,7 @@ import (
"fmt"
"log"
"net/http"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/lease"
@@ -77,6 +78,7 @@ const policyLeaseReleaseBudget = 5 * time.Second
// Door copy (collected for review in plan Task 15).
const (
policyLeaseHeldMsg = "another console (%s) is the policy writer for this deployment until %s. Make the change there, or try again after that"
+ policyLeaseHeldNoOwnerMsg = "another console is the policy writer for this deployment until %s. Make the change there, or try again after that"
policyLeaseUnavailableMsg = "could not take the policy writer lease. Try again"
policyLeaseMissingMsg = "this console is not set up to write app policies (no writer lease). Deploy a console built from current main"
policyLeaseLostMsg = "this console stopped being the policy writer during the change, so it may not have been published. Read the policy again before you retry"
@@ -204,8 +206,14 @@ func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal)
}
var held *lease.HeldError
if errors.As(err, &held) {
+ until := held.ExpiresAt.UTC().Format(time.RFC3339)
+ if strings.TrimSpace(held.Owner) == "" {
+ // A lock with no owner: name the expiry alone, never "()".
+ return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable,
+ fmt.Sprintf(policyLeaseHeldNoOwnerMsg, until)}
+ }
return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable,
- fmt.Sprintf(policyLeaseHeldMsg, held.Owner, held.ExpiresAt.UTC().Format(time.RFC3339))}
+ fmt.Sprintf(policyLeaseHeldMsg, held.Owner, until)}
}
s.policyLogf("console: policy writer lease: %v", err)
return policyHolding{}, unavailable
diff --git a/internal/console/policylease_test.go b/internal/console/policylease_test.go
index 2c5d1e1b4..2d087b941 100644
--- a/internal/console/policylease_test.go
+++ b/internal/console/policylease_test.go
@@ -264,6 +264,27 @@ func TestPolicyWriterNamesTheHolder(t *testing.T) {
rec.has(t, "console: policy writer lease: dial tcp: no route to host")
}
+// A lock with no owner still names its expiry, and never reads
+// "another console ()".
+func TestPolicyWriterHolderWithNoOwner(t *testing.T) {
+ t.Parallel()
+ t1 := time.Date(2026, 10, 25, 14, 30, 0, 0, time.FixedZone("x", 3600))
+ for _, owner := range []string{"", " "} {
+ f := &fakePolicyLeaser{err: fmt.Errorf("acquire lease: %w", &lease.HeldError{Owner: owner, ExpiresAt: t1})}
+ s, _ := policyLeaseServer(f)
+ _, ref := s.policyWriter(context.Background())
+ if ref == nil || ref.Status != http.StatusServiceUnavailable {
+ t.Fatalf("owner %q: refusal %+v, want 503", owner, ref)
+ }
+ if want := fmt.Sprintf(policyLeaseHeldNoOwnerMsg, "2026-10-25T13:30:00Z"); ref.Msg != want {
+ t.Fatalf("owner %q: refusal %q, want %q", owner, ref.Msg, want)
+ }
+ if strings.Contains(ref.Msg, "()") {
+ t.Fatalf("owner %q: refusal %q names an empty owner", owner, ref.Msg)
+ }
+ }
+}
+
func TestReleasePolicyWriterEndsTheHolding(t *testing.T) {
t.Parallel()
f := &fakePolicyLeaser{}