spec: fleet-internal engines rev 3 (Astra R2: 3 P1, 2 P2, all accepted)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+229
−72
docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md+176 −72docs/superpowers/specs/CODEX-FINDINGS-ENGINES-R2.md+53 −0
CI cancelled — superseded by a newer push — run details
CI log
=== gate === cd launchctx && go vet ./... && go test ./... -count=1 ok amadan.net/carlos/platform.git/launchctx 0.012s ? amadan.net/carlos/platform.git/launchctx/controlname [no test files] ? amadan.net/carlos/platform.git/launchctx/wireid [no test files] go vet ./... gofmt -l .: clean go test ./... -count=1 -timeout 30m ok github.com/carlosframework/platform 327.310s ? github.com/carlosframework/platform/cmd/changelog [no test files] ok github.com/carlosframework/platform/cmd/console 2.801s ok github.com/carlosframework/platform/cmd/moveprobe 92.347s ok github.com/carlosframework/platform/cmd/zddprobe 0.709s ok github.com/carlosframework/platform/internal/activator 251.446s ok github.com/carlosframework/platform/internal/agentsock 0.018s ok github.com/carlosframework/platform/internal/alerting 0.007s ok github.com/carlosframework/platform/internal/analytics 0.024s ok github.com/carlosframework/platform/internal/appconfig 2.478s ok github.com/carlosframework/platform/internal/apperr 0.078s ok github.com/carlosframework/platform/internal/applog 0.142s ok github.com/carlosframework/platform/internal/awsinv 0.004s ok github.com/carlosframework/platform/internal/boxchoice 0.005s ok github.com/carlosframework/platform/internal/boxid 0.531s ? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files] ? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files] ok github.com/carlosframework/platform/internal/boxvolumes 0.008s ok github.com/carlosframework/platform/internal/caps 0.014s ok github.com/carlosframework/platform/internal/certwarm 0.003s ok github.com/carlosframework/platform/internal/cgroup 0.705s ok github.com/carlosframework/platform/internal/changelog 0.330s ok github.com/carlosframework/platform/internal/claimactivation 0.811s ok github.com/carlosframework/platform/internal/clamd 0.054s ? github.com/carlosframework/platform/internal/cliflag [no test files] amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md b/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md| index 55770cbd4..794a21432 100644 |
| --- a/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md |
| +++ b/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md |
| @@ -1,9 +1,20 @@ |
| # Fleet-internal engines: a doc engine nobody on the internet can call |
| **Date:** 2026-10-21 |
| -**Revision:** 2. |
| +**Revision:** 3. |
| **Discussion:** amadan carlos/platform #335. Related: #328 (per-app egress allow). |
| +- **Rev 3** answers Astra round 2 (`CODEX-FINDINGS-ENGINES-R2.md`, reject, |
| + three P1s and two P2s, all accepted). The mark gains an "unclassified" |
| + state and a serving barrier: set before the release switch, cleared after |
| + it, and observed by the edge before adoption proceeds (R2-1). Every route |
| + is classified before the upgraded edge opens a listener (R2-2). The door |
| + carries engine calls only for callers with their own uid, so flat |
| + instances cannot call engines at all (R2-3). `carlos deploy` watches an |
| + internal engine with a console-signed watch ticket (R2-4). The refusal is |
| + a plain 404 that never redirects, and the no-redirect promise on :80 is |
| + scoped to the home box (R2-5). Marked **(codex R2-n)**. |
| + |
| - **Rev 2** answers Astra round 1 (`CODEX-FINDINGS-ENGINES-R1.md`, reject, |
| four P1s, all accepted) and the builder check, and records Paul's two |
| rulings of 2026-10-21 (§2.1). What makes a route internal moves from a |
| @@ -44,8 +55,9 @@ address opened in its fence. |
| - G2. An operator grants an app the use of a named engine, per app. Every |
| nested instance of the app, on any box and in every unit kind its code |
| runs as (run, generation, seed, `carlos run` command, legacy sidecar), |
| - can then call it, and so can the app's processes that hold a door key. A |
| - revoke takes effect within §6.4's bounds. |
| + can then call it, and so can the app's own host processes that hold a |
| + door key and run under their own uid (§5.4). A revoke takes effect within |
| + §6.4's bounds. |
| - G3. A nested instance calls an engine through a unix socket in its own run |
| directory. Its `IPAddressDeny=` fence is unchanged. |
| - G4. The engine box knows which app made each call and refuses a call from |
| @@ -59,8 +71,9 @@ address opened in its fence. |
| - **Unit-backed callers** (`carlos-app@`, DynamicUser). Ruling R2: later, if |
| a caller appears. They have no instance run directory and cannot be |
| enrolled for the door. Their routes can still *be* engines. |
| -- **Flat-layout instances as socket callers** (§6.2). A flat app uses the |
| - door. |
| +- **Flat-layout instances as callers** (§6.2). They share uid carlos with |
| + every other flat tenant, so nothing they hold can prove which app they |
| + are. An app that needs an engine moves to the nested layout. |
| - Metering or billing engine use. Member-requested grants. |
| - A general internal-route product for arbitrary services. |
| - Reaching an engine over TCP from a nested instance: its fence denies |
| @@ -147,31 +160,52 @@ the route. A door caller does the same with `Host: gotenberg`. |
| ### 5.1 The internal mark (every box, on its own routes) |
| -**(codex R1-2, R1-3)** Internal is a property of a route on the box that |
| -serves it, never of a hostname and never of a bucket record. |
| - |
| -- A new registry column `routes.internal` (0 or 1, default 0). |
| -- **Adoption stamps it.** Whenever adoption points a route's live version at |
| - a release, it has that release's manifest in hand; it sets |
| - `internal = 1` if the manifest declares `doc_engine`, and `0` otherwise, |
| - in the same registry write that records the new live version, before the |
| - edge can serve the new version. A version whose manifest adoption cannot |
| - read is not adopted (today's rule for manifests it does not understand |
| - extends to unreadable ones for this column). |
| -- **The grant stamps it too.** `carlos route --grant doc-engine` sets |
| - `internal = 1`; `--revoke doc-engine` leaves it to the next adoption |
| - stamp. A granted route is internal even if its release stopped declaring |
| - the capability. |
| -- **First pass after upgrade.** Routes adopted before this build have no |
| - stamp from adoption. The first capability pass of the new build stamps |
| - each from its staged live manifest. Until that pass has run, the edge |
| - treats a route as internal if it holds the grant; the residual (a route |
| - that declares `doc_engine`, holds no grant, and whose staged manifest is |
| - missing) is an engine running without its JIT and memory band, which |
| - Chromium does not survive, and the pass logs it by name. |
| -- A replacement or new box stamps at its own first adoption of the route, |
| - before it serves it. Nothing it reads from the bucket after that can |
| - unstamp it except a newer adopted release. |
| +**(codex R1-2, R1-3, R2-1, R2-2)** Internal is a property of a route on the |
| +box that serves it, never of a hostname and never of a bucket record. |
| + |
| +**The column.** `routes.internal` has three states: `NULL` (unclassified), |
| +`0` (public) and `1` (internal). Routes of a kind that serves no release |
| +(`static`, `service`, the status host, sidecars) are `0` by construction. |
| +An `instance` route is classified from the manifest of its live release: |
| +`1` if it declares `doc_engine`, `0` otherwise. The `doc-engine` grant also |
| +sets `1`, whatever the manifest says. The edge treats `NULL` like `1`. |
| + |
| +**Set before, clear after: the serving barrier (codex R2-1).** Adoption |
| +repoints the live symlink (`release/adopt.go:1751`), restarts |
| +(`adopt.go:1831`) and records the version afterwards (`adopt.go:2051`), |
| +and the edge serves an in-memory map refreshed on a 2 s registry poll |
| +(`edge.go:2298`). So the order is explicit: |
| + |
| +- **Becoming internal** (the incoming release declares `doc_engine`, the |
| + route is `0`): adoption writes `1`, then waits until the edge reports a |
| + loaded registry state that includes that write, then repoints and |
| + restarts. The edge publishes the registry change it last loaded |
| + (a counter in a file under `/run/carlos` that `Reload` writes after it |
| + swaps the map in; a failed `Reload` does not advance it). The wait is |
| + bounded (30 s); on timeout adoption does not repoint this pass, the mark |
| + stays `1`, and the next pass retries. The old, non-engine release is |
| + refused publicly for that interval, which costs availability, never |
| + exposure. |
| +- **Becoming public** (the incoming release does not declare `doc_engine`, |
| + the route is `1`, and it holds no grant): adoption repoints and restarts |
| + first, records the version, and only then writes `0`. A failed or |
| + rolled-back adoption leaves `1`. |
| +- **Rollback** is an adoption like any other: to a declaring release, set |
| + before; away from one, clear after. |
| +- The same barrier applies to a grant: `carlos route --grant doc-engine` |
| + writes `1` and returns once the edge has loaded it (or says it timed out). |
| + |
| +**Classification before serving (codex R2-2).** The new column arrives |
| +`NULL` for every existing route. Before the upgraded agent opens any edge |
| +listener, it classifies every `NULL` instance route from the staged |
| +manifest of its live version (`release.ReadStagedManifest`), and if the |
| +sibling is missing, from the release's signed manifest in the bucket, as |
| +adoption's back-fill does. A route it still cannot classify stays `NULL`, |
| +is refused publicly, and is named in a log line every pass until the |
| +capability pass classifies it. This can make a route with no readable |
| +manifest unreachable until the bucket answers; that is the fail-closed |
| +choice. A route the agent creates later (provisioning, a move) is |
| +classified by its first adoption, which runs before the route is served. |
| **What the edge does with it** (`ServeHTTP`): |
| @@ -182,19 +216,23 @@ serves it, never of a hostname and never of a bucket record. |
| (`edge.go:1364`) copy the target's upstream, so every name and path that |
| resolves to an internal route is covered, in any spelling the lookup |
| accepts. |
| -- An internal route admits only an engine call (§5.4). Anything else gets |
| - `unknownHost`. There is no owner exception: the engine app's own |
| - processes reach it by granting the engine to their own app. |
| -- The :80 handler serves the ACME challenge for an internal route's |
| - hostnames and nothing else: no redirect. |
| +- An internal route admits only an engine call (§5.4). Everything else gets |
| + the **internal refusal**: a plain `404 not found`, `Cache-Control: |
| + no-store`, never the pending page and never the fallback redirect |
| + (`unknownHost` does both, `edge.go:2205`, so it is not reused). |
| +- There is no owner exception: the engine app's own processes reach it by |
| + granting the engine to their own app. |
| +- **:80 on the home box** serves the ACME challenge for an internal route's |
| + hostnames and the internal refusal for everything else: no redirect. |
| + **:80 on another box** knows only the home map, so it redirects to https |
| + as it does for any host homed elsewhere (`edge.go:2262`), and the https |
| + request is then backhauled and refused (codex R2-5). The redirect names |
| + nothing DNS and certificate transparency do not already publish. |
| - `Allowed` (certificate issuance) is unchanged: the hop's TLS needs a |
| - publicly trusted certificate on the home box. The hostname is not a |
| - secret (certificate transparency publishes it); the refusal is about |
| - serving. |
| -- A box that does not home the host learns nothing new. It backhauls a |
| - public request as today, as an ordinary hop with no caller, and the home |
| - box refuses it. A home box on an older build is the one gap, closed by |
| - deploy order (§7). |
| + publicly trusted certificate on the home box. |
| +- A box that does not home the host backhauls a public request as an |
| + ordinary hop with no caller, and the home box refuses it. A home box on |
| + an older build is the one gap, closed by deploy order (§7). |
| ### 5.2 Engine names and grants (console) |
| @@ -288,12 +326,19 @@ instance: `/run/carlos/instances/<iid>/engines.sock` (at most 75 bytes). |
| set it): the run launch file, each generation's launch file, the seed's |
| variables and the command unit's environment. |
| -**The door (codex R1-5).** A door caller whose `Host:` has no dot and names |
| -an engine is an engine call: after `doorRequest` admits the key, the door |
| -mark is replaced by an engine mark for the key's account and app, and the |
| +**The door (codex R1-5, R2-3).** A door caller whose `Host:` has no dot and |
| +names an engine is an engine call only if the connecting process runs under |
| +a uid other than carlos (SO_PEERCRED): an enrolled host-app user, whose door |
| +key sits in files only that user can read. A caller running as carlos (a |
| +flat instance, or anything else sharing the edge's uid) gets 404 `engine |
| +not granted`, because every flat tenant can read every other's config |
| +(`layout/preflight.go:181`, `activator/paths.go:35`), so a door key in a |
| +flat app's hands proves nothing. When the uid passes and `doorRequest` |
| +admits the key, the door mark is replaced by an engine mark for the key's account and app, and the |
| door's own scope check (`doorOwnsRoute`) does not run for it; the engine |
| grant check (step 3 below) replaces it. A door caller whose `Host:` is an |
| -engine's real hostname gets `unknownHost` like any other non-engine call. |
| +engine's real hostname reaches the internal route without an engine mark, |
| +so it gets the internal refusal (§5.1) like any other non-engine call. |
| **On the calling box.** |
| @@ -322,7 +367,7 @@ way in, so only an edge can speak it. |
| **On the engine box.** An engine-marked request reaches a route only if the |
| route is internal, the mark's engine names the request's hostname in the |
| grant table, and the table grants that engine to the mark's app. Otherwise |
| -`unknownHost`. Then: |
| +the internal refusal (§5.1). Then: |
| - the per-app cap (§5.5); |
| - the import gate, unchanged; |
| @@ -371,6 +416,30 @@ The hourly capability pass on each box logs once per pass: |
| - an internal route that declares `doc_engine` without the grant (no JIT |
| or memory band; it will not render). |
| +### 5.8 Watching an engine deploy (codex R2-4) |
| + |
| +`carlos deploy` verifies a release by polling its public URL |
| +(`deploy.go:1426`, `:1536`), which an internal route now refuses. For a |
| +target whose release declares `doc_engine`, the watch carries a **watch |
| +ticket** instead: |
| + |
| +- The console mints it on the deploy request it already authorizes: signed |
| + with the root release key under its own domain (`carlos watch v1\n`), |
| + bound to one hostname and one version, expiring 5 minutes after minting. |
| + It is returned to the CLI with the promote response. |
| +- In bucket mode, where no console is involved, the CLI signs the ticket |
| + itself with the root release key it already holds to sign the promote. |
| +- The CLI sends it as `X-Carlos-Watch-Ticket` on each poll. The home box's |
| + edge admits a request carrying a valid ticket to the internal route only |
| + for `GET` or `HEAD` with no body, on `/` or the release's declared probe |
| + path, strips the header, and dispatches as usual (no cache, as the watch |
| + already busts it). Anything else with a ticket gets the internal refusal. |
| +- The ticket crosses a backhaul as a header and is verified only on the home |
| + box. It names no app and grants no engine call. |
| +- A ticket is a credential for reading a version, not for converting a |
| + document: the probe paths are the app's own choice, and Gotenberg's |
| + `/health` is a fine probe. |
| + |
| ## 6. Correctness rules |
| ### 6.1 Identity |
| @@ -384,17 +453,23 @@ and not solved. |
| ### 6.2 Flat layout |
| -A flat instance has no engine socket of its own, and cannot use a nested |
| -sibling's (its process is not in that sibling's slice). A flat app reaches |
| -an engine through the door with a door key. Enabling the door on a box |
| -(`CARLOS_EDGE_DOOR`) and minting the app's key are operator steps in the |
| -runbook, not part of this build. |
| +**(codex R2-3)** A flat instance cannot call an engine. It has no engine |
| +socket, it cannot use a nested sibling's (its process is not in that |
| +sibling's slice), and the door refuses engine calls from uid carlos. Flat |
| +tenants share one uid and can read each other's files, so no credential a |
| +flat app holds can say which app it is. An app that needs an engine moves to |
| +the nested layout, the default since v0.25.0. A host-app process under its |
| +own uid uses the door; enabling the door on a box (`CARLOS_EDGE_DOOR`), |
| +enrolling the user (`carlos bootstrap --socket-consumer`) and minting the |
| +key are operator steps in the runbook, not part of this build. |
| ### 6.3 The public never reaches an internal route |
| -- The mark lives in the box's own registry, written by adoption or the |
| - grant before the route is served (§5.1). It needs no bucket read at |
| - serve time and survives restarts. |
| +- The mark lives in the box's own registry and survives restarts. It is set |
| + before a declaring release can serve and cleared only after a |
| + non-declaring one is live, with the edge's loaded state as the barrier; |
| + an unclassified route is refused (§5.1). No bucket read happens at serve |
| + time. |
| - The check runs on the final upstream's route, after every re-resolution, |
| before every dispatch branch (§5.1). |
| - Old non-home boxes backhaul public requests as ordinary hops; the home box |
| @@ -410,19 +485,24 @@ so a revoke is in force once either has read it. |
| ## 7. Deploy order and rollout |
| -- **Every engine's home box takes this build first.** From then on its |
| - doc-engine routes are internal (R1). Before that, the operator moves each |
| - public caller of a doc-engine route onto the socket or the door. On the |
| - flagship and dev, list doc-engine routes and their callers before the |
| - roll. |
| -- Calling boxes and the console follow. With no `engines.json` the grant |
| - table is empty and every engine socket answers 404; nothing else changes. |
| -- A calling box on an older build cannot make engine calls (it has no |
| - socket and sends no caller). A home box on an older build serves its |
| - engine publicly, which is today's behaviour and the reason for the order |
| - above. |
| -- Roll order as usual: dev boxes, dev console, check, flagship boxes, |
| - flagship console. |
| +- **Survey first.** On dev and the flagship, list every route whose live |
| + release declares `doc_engine` or holds the grant, and who calls it. A |
| + route with public callers is cut off the moment its home box takes this |
| + build (R1), and its callers can only switch once the console and their own |
| + boxes have it too, so that route's cut is scheduled with its owner. A |
| + route with no public callers needs nothing. |
| +- **Boxes, then the console**, in the usual order: dev boxes, dev console, |
| + check, flagship boxes, flagship console. Engine calls need the calling |
| + box, the home box and the console all on this build. |
| +- **Then names and grants.** With no `engines.json` the grant table is empty |
| + and every engine socket answers 404; nothing else changes. `carlos engines |
| + add` and `grant` follow the roll, and callers switch to the socket. |
| +- A calling box on an older build cannot make engine calls (no socket, no |
| + caller on the hop). A home box on an older build serves its engine |
| + publicly, which is today's behaviour and why boxes roll before anything is |
| + named. |
| +- A box whose kernel lacks SO_PEERPIDFD opens no engine sockets; the survey |
| + checks kernels (Debian 13's 6.12 has it). |
| ## 8. Tests that prove it |
| @@ -432,13 +512,27 @@ process, real unix sockets and a real cgroup check where the kernel allows |
| - Public requests to an internal route through its host, an alias, a path |
| map, a wildcard, a mixed-case spelling and a login POST whose `next` |
| - re-resolves to it: all `unknownHost`, on :443 and :80 (no redirect); the |
| - ACME challenge still answers. |
| + re-resolves to it: all the internal refusal (a plain 404, never a redirect, |
| + even with a fallback URL set), on :443 and on the home box's :80; the ACME |
| + challenge still answers. |
| - A public request reaching a non-home box is backhauled and refused by the |
| home box. |
| - A route adopted from a release declaring `doc_engine` is internal before |
| its first request, on a fresh registry, with the bucket unreachable after |
| adoption. |
| +- Becoming internal: with the edge's reload held back, adoption of a |
| + declaring release does not repoint; once the edge loads the mark, it does, |
| + and no public request reaches the new release at any point. Becoming |
| + public: the mark clears only after the non-declaring release is live; a |
| + failed adoption leaves it set. |
| +- An upgraded box with an unclassified declaring route and a missing staged |
| + manifest refuses it publicly before its first request, and classifies it |
| + from the bucket. |
| +- A door engine call from uid carlos is refused with a valid, granted key; |
| + the same key from an enrolled host-app uid succeeds. |
| +- A deploy watch with a valid ticket reads the version on `/` and the probe |
| + path; the ticket on a POST, another path, an expired ticket or one for |
| + another host gets the internal refusal. |
| - A socket call from a process in the instance's slice reaches the engine on |
| another edge; from a process outside the slice (a flat child, a sibling |
| slice) the connection is closed unread. |
| @@ -482,7 +576,12 @@ it, not only over a private network. |
| meter and analytics run as for any request. |
| - No owner exception on internal routes: the engine app grants itself. |
| - Tunnel-homed engines are refused (502), not supported. |
| -- The flat layout's door is an operator step, not built here. |
| +- Flat instances cannot call engines at all (§6.2); host-app processes |
| + under their own uid use the door, enabled by the operator. |
| +- Unclassified routes are refused publicly (fail closed) even though that |
| + can make a route with no readable manifest unreachable while the bucket |
| + is down. |
| +- The deploy watch ticket rather than skipping verification for engines. |
| - The engine box re-checks the grant (G4) rather than trusting the calling |
| box. |
| @@ -498,8 +597,13 @@ it, not only over a private network. |
| | R1-6 socket lifecycle | Accepted: instance-level owner (§5.4) | |
| | R1-7 transport and body | Accepted: engine transport, streamed body with abort (§5.5) | |
| | Builder Q1 unit-backed callers | Ruling R2: out of scope | |
| -| Builder Q2 door for flat apps | Operator step (§6.2) | |
| +| Builder Q2 door for flat apps | Superseded by R2-3: flat instances cannot call engines; host-app door is an operator step (§6.2) | |
| | Builder Q3 engine owner's own processes | Grant itself (§5.1) | |
| | Builder Q4 gate, meter | §5.4 | |
| | Builder Q5 socket lifetime | §5.4 | |
| | Builder Q6 several homes, tunnels | Per-box cap; tunnels refused (§5.4, §5.5) | |
| +| R2-1 stamp does not precede serving | Accepted: set before, clear after, edge-loaded barrier (§5.1) | |
| +| R2-2 upgrade serves unclassified engines | Accepted: NULL state, classification before listeners, NULL refused (§5.1) | |
| +| R2-3 flat callers borrow a door key | Accepted: door engine calls need a non-carlos uid; flat cannot call (§5.4, §6.2) | |
| +| R2-4 deploy watch cannot verify | Accepted: watch ticket (§5.8) | |
| +| R2-5 :80 on non-home boxes; unknownHost redirects | Accepted: plain 404 refusal; no-redirect scoped to the home box (§5.1) | |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-ENGINES-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-ENGINES-R2.md| new file mode 100644 |
| index 000000000..46adaf6b5 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-ENGINES-R2.md |
| @@ -0,0 +1,53 @@ |
| +| Round 1 finding | Round 2 disposition | |
| +|---|---| |
| +| R1-1: flat tenant borrows a nested socket | **Fixed for the reported direct connection:** §5.4 authenticates the peer’s cgroup. The separate door-credential attack below remains. | |
| +| R1-2: aliases and path routes remain public | **Fixed:** §5.1 checks the final resolved route, including login re-resolution. | |
| +| R1-3: new box starts public | **Partly fixed:** §§5.1 and 6.3 remove the bucket-mirror dependency, but the stamping and migration mechanisms still leave public-access windows, detailed below. | |
| +| R1-4: revoked grants return after restart | **Fixed for retained local state:** §5.3 persists the record and revision floor. Fresh/rebuilt boxes have an explicitly stated replay exception. | |
| +| R1-5: door exceptions fail across routing | **Resolved:** §§5.1 and 5.4 remove the owner exception and define the door-to-engine transition. | |
| +| R1-6: socket lifecycle conflicts with generations | **Fixed:** §5.4 assigns ownership to an instance-level manager and covers pre-seed creation and environment delivery. | |
| +| R1-7: transport and body limits | **Resolved at spec level:** §§5.4–5.5 specify a separate transport, whole-call deadline, upgrade refusal and streamed-body abort. | |
| + |
| +Reviewed spec `8cf7cc8f` against locally available `origin/main` at `f54aa2fc`. The implementation files cited below match that revision. |
| + |
| +1. **P1 — The prescribed registry stamp does not precede serving the new release (§§5.1, 6.3).** |
| + |
| + Start with a public route, then adopt a release declaring `doc_engine`. Following the specified mechanism—writing `internal` alongside the recorded version—allows the new process to serve before the mark is written. Even after that write, requests can use the edge’s previous route snapshot. |
| + |
| + Adoption repoints the live symlink at [internal/release/adopt.go:1751](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/release/adopt.go:1751), invokes the restart at [adopt.go:1831](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/release/adopt.go:1831), and only afterward records the version at [adopt.go:2051](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/release/adopt.go:2051). The edge serves an in-memory route map, replaced by `Reload`, with registry changes detected on a two-second poll; reload failures retain the previous map ([edge.go:993](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/edge.go:993), [edge.go:2298](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/edge.go:2298)). |
| + |
| + **Suggested fix:** Define a serving barrier: public admission must close, and the serving edge must observe that closure, before repointing or starting the engine release. Keep the refusal through failed adoption and rollback until the running release and classification agree. An atomic registry update alone does not provide this ordering. |
| + |
| +2. **P1 — Upgrade migration explicitly serves unclassified declared engines (§§5.1, 7).** |
| + |
| + Have an existing release that declares `doc_engine` without holding the grant. Upgrade its edge before the first capability pass. Its new column defaults to zero, and the temporary check considers only the grant, so public requests still reach it. This happens even with a readable staged manifest until the pass runs. A missing manifest extends the exposure. |
| + |
| + The capability pass runs in the root adoption path ([internal/host/agent.go:4782](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/host/agent.go:4782)); its timer deliberately starts after the edge ([carlos-adopt.timer:7](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/host/units/carlos-adopt.timer:7)). Missing staged manifests produce a leave-alone result ([caps_pass.go:106](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/host/caps_pass.go:106)). The grant controls resource and sandbox settings, not HTTP admission ([internal/caps/render.go:189](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/caps/render.go:189)). An engine’s inability to run Chromium does not prove that its HTTP endpoints cannot answer, and G1 covers every declaring release. |
| + |
| + **Suggested fix:** Complete classification before exposing existing routes on the upgraded edge. Preserve an explicit “unclassified” state and refuse public dispatch for it when the manifest cannot be read. Do not infer public safety from expected renderer failure. |
| + |
| +3. **P1 — Flat callers can borrow another app’s door credential (§§5.4, 6.1–6.2).** |
| + |
| + Put flat apps A and B on one box. Grant B an engine and supply B’s door key through its ordinary configuration. A reads B’s key and sends `Host: <engine-name>` through the shared door. The door authenticates B’s token; §5.4 converts that into B’s engine identity. Both engine-grant checks pass. The new cgroup check never runs on this path. |
| + |
| + Flat children execute under the edge’s `carlos` identity ([carlos-edge.service:28](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/host/units/carlos-edge.service:28), [backend_exec.go:2236](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/activator/backend_exec.go:2236)). Their environment files share the data directory ([paths.go:35](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/activator/paths.go:35)); the existing layout code explicitly identifies same-UID cross-tenant filesystem access ([preflight.go:181](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/layout/preflight.go:181)). Door authentication derives identity entirely from the bearer token ([door.go:103](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/door.go:103)). |
| + |
| + **Suggested fix:** Require an enforceable isolation prerequisite for engine-authorized door credentials. Flat callers sharing `carlos` with mutually untrusted processes cannot satisfy the stated no-borrowing guarantee. Restrict that deployment shape or provide separate credential and process isolation. |
| + |
| +4. **P2 — Internal engines cannot complete normal deployment verification (§§5.1, 5.4, 7).** |
| + |
| + Deploy a healthy engine after its home box upgrades. Adoption succeeds, but `carlos deploy` polls its public HTTPS URL. The request has no engine identity, so the new refusal prevents verification indefinitely. Granting the engine to itself does not help a CLI running off-box. |
| + |
| + Deployment verification uses a public origin ([deploy.go:1426](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/deploy.go:1426)) and an ordinary `client.Get` for both header and build-probe verification ([deploy.go:1536](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/deploy.go:1536)). Success requires a matching build over a 2xx response ([deploy.go:1563](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/deploy.go:1563), [deploy.go:1588](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/deploy.go:1588)). |
| + |
| + **Suggested fix:** Assign an authenticated API-backed verification path for internal engines and wire it into deployment watching. It must verify the running service without requiring an on-box socket or reopening a public endpoint. |
| + |
| +5. **P2 — Non-home boxes cannot implement the promised port-80 refusal (§§5.1, 6.3, 8).** |
| + |
| + Home an internal engine on box A, then send an HTTP request for it to upgraded box B. Section 5.1 says B learns nothing new. Its existing handler therefore sees a remotely routable hostname and returns an HTTPS redirect, contrary to the specified no-redirect behavior. |
| + |
| + The port-80 handler checks the remote home map and redirects without contacting the home box ([edge.go:2262](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/edge.go:2262), [edge.go:2284](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/edge.go:2284)). Also, using `unknownHost` for the local refusal can itself redirect when a fallback URL is configured ([edge.go:2205](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-fleet-internal-engines/internal/edge/edge.go:2205)). |
| + |
| + **Suggested fix:** Define a non-redirecting refusal explicitly. Either provide non-home HTTP listeners with the classification needed to apply it, or scope the no-redirect contract to home boxes and document the remote redirect followed by HTTPS refusal. |
| + |
| +**Overall verdict: reject.** The revised design closes several round 1 paths, but public admission still lacks safe activation ordering, and flat door callers do not satisfy the identity anti-goal. |
| \ No newline at end of file |