carlos / platform Public
Census: console invite delivery
Status: RULED as census-ruling-console-invite-delivery (platform-1e, R1–R5), on 5aef01db. The ruling is .superpowers/sdd/2026-10-02-console-invite-delivery/census-ruling.md. The overlay is loaded last in TestPlan14RootReadCensus, its rows carry rev3=census-ruling-console-invite-delivery, and the seal is moved to fcdf9de0…d7a3. platform-1e rules as the Plan 14 census owner. The proposal below is kept as it was put, with the PROPOSED wording brought up to date where the ruling settled it.
The ruling
- R1. Accepted. The seven units (1 to 7 below) are accepted legacy under the identities named:
serve,NewServer,Keymail.do,Keymail.sealEnvelopeandStore.CountPendingInvitesrun in the console (cmd/console serve under console.service, user carlos-console); the two region-host files are run by tofu on an operator's machine. The basis is that the rootcarlosbinary links neitherinternal/consolenorinternal/mail. No check pinned that before this ruling, so one is added:TestRootBinaryLinksNoConsoleOrMail(internal/provision/root_binary_deps_test.go:18) runsgo list -deps .for linux, darwin and windows and fails if either package, or anything under it, appears. It skips whengois not on PATH. This ruling is the separate scope ruling the legacy freeze needs, so the seal moves. - R2. Accepted.
Keymail.SendApp's read row is retired by an H3 census-ruling removal, in the form census ruling carlos-run used forapiSession.getJSON(plan14-root-reads-carlos-run.tsv:126-127). Its one read,crypto/rand.Readfor the envelope id, moved unchanged intosealEnvelope, the same class. - R3. Out of census scope. The
ses:SendEmailgrant goes on the shared host role, which every process holding the box's instance credentials can use. Merging is fine, because the policy has count 0 by default. It needs Paul's own security ruling before anyone setsconsole_mail_from. The rollout gate, verbatim: "Paul approves the host-role SES grant (or a console-only role) before consolemailfrom is set". Whether tenant code can reach the instance credentials is answered under "R3: who can reach the instance credentials" below. - R4. Rewritten. The
PROPOSED-console-invite-deliverymarker is nowcensus-ruling-console-invite-deliveryin every row, as box-publish-record and the step 6 overlays did, and the overlay header names the ruling. The@legacyfingerprints were recomputed for the new basis text. The seal, recomputed with the overlay loaded after box-publish-record, isfcdf9de0ba39db40d6bc7d1d2137d4f40d8248df8d286a380b2eae4e8b31d7a3, the value the drafter computed for this form. - R5. Not a merge blocker (HostClaim is off, and ruling 6 holds). It is a claim-activation blocker for any console that sends mail, filed on #250. With
CARLOS_HOST_CLAIMon, the console mailer'sEnsureDomainandVerifyDomainmust go through the claim mail gate: either console-mail is registered as a platform identity the admitter knows, or the mailer refuses to start when HostClaim is on. Until then a console under claims must not have console mail enabled. - Re-digest after 0da9b965 (HostClaim guard passes hostClaim into consoleMailer; no new read site): serve and cmd/console envelope re-digested, seal fcdf9de0 → 53b3cbb6, awaiting platform-1e's confirmation.
R3: who can reach the instance credentials
The question is whether tenant code on a box can reach the EC2 instance metadata service (IMDS, 169.254.169.254) and so the host role's credentials. If it can, it can send as console_mail_from once the grant is on.
What holds today, from this repo:
- Exec-backed instances in the flat layout can reach IMDS. The flat layout is the default:
ExecBackend.Startruns every row that is not nested, fenced or in conflict as an exec child of the agent (internal/activator/backend_exec.go:1829-1837; flat is "today's exec child",internal/activator/layout.go:15-17). Those children run as uid carlos insidecarlos-edge.service's delegated cgroup (internal/host/units/carlos-edge.service:28User=carlos,:64"The agent and its flat children",:95-101Delegate=). That unit has noIPAddressDeny=line, because the agent itself reads IMDS (internal/host/agent.go:2964,internal/host/memwatch.go:186-189). The only other fence is the nftables skuid gate, and it lets uid carlos through by construction (memwatch.go:189). - Exec-backed instances in the nested layout (#126) cannot. They are still uid carlos, not a separate uid (
internal/host/units/carlos-instance@.service:4and:28), but each template carries a unit-levelIPAddressDeny=that covers 169.254.169.254/32 and fd00:ec2::254/128 (carlos-instance@.service:142,carlos-instance-sidecar@.service:142,carlos-instance-cmd@.service:133,carlos-instance-gen@.service:115,carlos-instance-seed@.service:143). systemd applies it as a cgroup BPF firewall, so it holds whatever the nftables rules say. - Unit-backed instances (DynamicUser) cannot, unless they hold
host_role.carlos-app@.service:40isDynamicUser=yesand:69isIPAddressDeny=169.254.169.254/32 fd00:ec2::254/128. On a hardened box the skuid gate also refuses dynamic uids (carlos-app@.service:9-15). A tenant grantedhost_rolegets a drop-in withIPAddressAllow=169.254.169.254/32 fd00:ec2::254/128, which wins over the deny (internal/caps/render.go:252-259), and then only the skuid gate stands between it and the role.
What depends on settings this repo does not hold:
- The skuid gate. It is written by the infra repo's
scripts/harden-host.sh, not by anything here (internal/host/adopt_advice.go:204-207). A box without it has no nftables fence at all (adopt_advice.go:156; tito, issue #351, percarlos-app@.service:10-11). On such a box ahost_roleunit tenant reaches IMDS too. - IMDS options.
aws_instance.host(infra/modules/region-host/main.tf:482-511) sets nometadata_options, sohttp_tokensandhttp_put_response_hop_limittake the AMI's and the account's defaults (the scanner host setshttp_tokens = "required"and a hop limit of 1,infra/modules/scanner-host/main.tf:126-129; the region host does not). Neither setting would fence these tenants anyway. They share the host's network namespace, so a hop limit of 1 reaches them, and any local process can do the IMDSv2 token PUT. - Which layout a box's rows are in. A row is nested only after plan 4's migration moves it. Until then, on any box that runs flat exec-backed tenants, including the console's box if it hosts them, tenant code can fetch the host role's credentials and, with the grant on, send as
console_mail_from. A console-only role, or a skuid gate that separates the console's uid from carlos, would close that.
Branch design-console-invite-delivery, head 05cfbec0, with origin/main merged in at c80e2f3b. Design: docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md.
What changed
An owner's invite goes out by Keymail federation first (into the invitee's main inbox, sent as the console, no grant needed). If Keymail can't deliver, it goes by the console's own email. If that fails too, the owner gets the link to pass on. Two limits refuse an invite before its row exists: 20 waiting per account, and 20 an hour.
What the root-read census sees:
internal/console/keymail.go:Keymail.doreturns a typedkeymailErrorfor a non-200 answer. SendApp's envelope sealing moves, unchanged, into a newKeymail.sealEnvelope, whichSendAppand the newSendFederated(keymailfed.go) share.internal/console/server.go:NewServersets the invite delivery fields.handleInviteis rewritten, but it has no read site of its own.internal/console/store.go: newStore.CountPendingInvites.cmd/console/main.go:serve()builds the console mailer (consoleMailer) and starts its identity ensure in a goroutine.infra/modules/region-host: an optionalses:SendEmailgrant on the host role (main.tf), and the two variables that switch it on (variables.tf).- New files with no read site:
internal/console/consolemail.go,keymailfed.go,keymailpow.go,internal/mail/consolemail.go,internal/mail/sessend.go.internal/mail/walk.gogains the console-mail teardown guards.
None of this runs as root. The root carlos binary links neither internal/console nor internal/mail: go list -deps . names neither, and the only importers of internal/mail are cmd/console and internal/console. TestRootBinaryLinksNoConsoleOrMail pins that (R1). The .tf files are run by tofu on an operator's machine. No binary embeds them and no box reads them.
Root-read census (TestPlan14RootReadCensus)
Overlay internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv, to load last, after plan14-root-reads-box-publish-record.tsv.
After the merge the test reports 18 discrepancies. With the overlay loaded, 17 clear and one is left, "changed legacy freeze; separate scope ruling required", which is the seal. With the ruled seal set as well, every TestPlan14* test passes.
Every row is accepted-legacy and tagged rev3=census-ruling-console-invite-delivery (R4). Each basis names the executing identity, in the form census rulings carlos-run and seeded-instances use for console rows ("the console (cmd/console serve under console.service, user carlos-console): ...").
Changed units, re-frozen with their read sites unchanged
Each of these was a "changed root read" and a "changed legacy declaration".
cmd/console/main.go:serve: data-literal@3446,4547,4784, digesta9ecdd3b…9825. Main froze it under seeded-instances at @3331,4432,4669. The same three literals moved by 115 bytes, which is the one inserted line at main.go:297 (consoleMail, ensureConsoleMail := consoleMailer(...)). The literals are theCARLOS_REGISTRYdefault/data/registry.db(main.go:333) and theCONSOLE_DBdefault/data/console/console.db, used twice (main.go:352 and 355). The other changes areConsoleMail: consoleMailin Config, and a goroutine bounded at two minutes that runsensureConsoleMail(main.go:457). The mailer is built over the release store, provisioner and DNS writer that serve already holds.consoleMaileritself (main.go:995) has no read site. Proposed: accepted-legacy, the console.
internal/console/server.go:NewServer: data-literal@9108, digest4d82a686…b032. Main had it at @8926. The literal is the route pattern"GET /a/{sqid}/apps/{app}/logs/data"(server.go:1416), not a path. The change setsconsoleMail,inviteTimeoutandinviteKeymailShare(server.go:1309), and bindssendFederatedbesidesendApp(server.go:1329). No goroutine is started. The record-write census re-digested the same unit in 05cfbec0. Proposed: accepted-legacy, the console.
internal/console/keymail.go:Keymail.do: file:ReadAll@594, which is the same site as on main. Only the digest moved, toe2f193e4…e7be. The read isio.ReadAll(io.LimitReader(res.Body, 1<<20))(keymail.go:344), which reads Keymail's HTTP response body. The change is the error value (keymail.go:356). Its Error() text is the same as before, and it adds Status and Msg so thatIsKeymailBlockedcan classify the answer. Proposed: accepted-legacy, the console.
infra/modules/region-host/main.tf:external: digest6ae47ec3…9c5b. Addsaws_iam_role_policy.host_console_mail(main.tf:702). It has count 0 unlessconsole_mail_fromis set. It grantsses:SendEmailon the one sending identity, thecarlos-babconfiguration set and its tenants, on condition thatses:FromAddressequalsconsole_mail_from(main.tf:717). Proposed: accepted-legacy, tofu on an operator's machine. No read is added. See question 3: this is an authority grant on the box's role, not a read.
infra/modules/region-host/variables.tf:external: digestffdad585…250e. Addsconsole_mail_from(variables.tf:221, validated as one lowercase address) andconsole_mail_region(variables.tf:231). Both are empty by default. Proposed: accepted-legacy, tofu on an operator's machine.
New units
internal/console/keymail.go:Keymail.sealEnvelope: "unclassified root read … file:Read@387", digestb140fde1…7658.file:Read@387isrand.Read(idb)at keymail.go:258, andrandiscrypto/rand(keymail.go:21). It reads the 16-byte envelope id from the kernel's random source. It reads no file, store or box state. It is the site main froze inSendAppas file:Read@949 (accepted-legacy, J4 root-input), moved over unchanged with the rest of the sealing. Proposed: accepted-legacy, the console, the same class as the site it came from.
internal/console/store.go:Store.CountPendingInvites: "unclassified root read … file:QueryRow@102", digest1733078d…18e5. One SQLiteQueryRow(store.go:1400):SELECT COUNT(*) FROM invites WHERE account_id = ? AND accepted_at = 0 AND expires_at >= ?. It runs on the console's own database (CONSOLE_DB). Its one caller is handleInvite's pending cap (server.go:2446), and it can only refuse an invite. Its siblingsStore.PendingInvites,CreateInviteandRevokeInviteare accepted-legacy (J4 root-input) on main, and so are all 84store.gorows. Proposed: accepted-legacy, the console.
Retired unit
internal/console/keymail.go:Keymail.SendApp: "stale root read" and "changed legacy declaration". It is still declared (keymail.go:279) and reads nothing now. Its one read moved intosealEnvelope, which it calls at keymail.go:293. A frozen legacy row cannot have empty kinds (the compare reports it stale), so it has to be retired, not re-frozen. Proposed:@removeand@removed … H3, the form census ruling carlos-run used forapiSession.getJSONandensureExe. The loader accepts an H3 removal only with acensus-ruling-basis, so the row readscensus-ruling-console-invite-delivery: …(R2). A removal is not part of the seal.
Source envelopes
- The four "changed root source envelope" rows are re-pinned: -
cmd/consoleto2af70021…c0c6-infra/modules/region-hostto82e90b10…56e7-internal/consoleto18fc22e0…5756-internal/mailtoc45dd340…e317
The scan finds no read site in any new file. In the changed files, the only read-site changes are units 1 to 8. One thing the scanner does not see: mail.NewSESEmailSender (sessend.go:22) calls awsconfig.LoadDefaultConfig. That is the console's own credential chain, which on a box is the host role. It is already listed in TestClaimAuthoritySourceCensus as a send-only SES client (05cfbec0).
Seal
The overlay adds seven @legacy rows, which takes the frozen set from 2147 to 2154. Computed with the overlay loaded after box-publish-record:
- As drafted (the marker kept on ruling, as census ruling seed did):
5ebadd0439ec247784a13739c74ab7363e22a3a9fb8da3d7ef03a76f1ab75468 - With the tag rewritten to
rev3=census-ruling-console-invite-delivery, as R4 ruled, and the seal the test now uses:fcdf9de0ba39db40d6bc7d1d2137d4f40d8248df8d286a380b2eae4e8b31d7a3
The test edit as proposed is below. As applied, it sits after the box-publish-record load in TestPlan14RootReadCensus with R4's seal:
// Census overlay console-invite-delivery (console invite delivery),
// loaded last, under census-ruling-console-invite-delivery (platform-1e):
// ... the ruling's lines ...
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
seal = "5ebadd0439ec247784a13739c74ab7363e22a3a9fb8da3d7ef03a76f1ab75468"Questions for platform-1e
- Do you accept the seven units (1 to 7) as accepted legacy, under the executing identities named (the console as carlos-console, and tofu on an operator's machine), with seal
5ebadd04…5468? - Should
Keymail.SendAppbe retired by an H3 census-ruling removal (the carlos-run getJSON form), now that its only read lives insealEnvelope? - The
ses:SendEmailgrant is on the shared host role. Any process that holds the box's role credentials (root, the agent or the console) can send asconsole_mail_from, and only as that address. That is a grant, not a read. Is it inside this census's scope, or does it need its own ruling elsewhere? - Keep the
PROPOSED-console-invite-deliverymarker in the frozen bases (seal5ebadd04…), or rewrite it to acensus-ruling-tag (sealfcdf9de0…)? - This is not a census item, but you own host claims, so it is flagged for you. The console mailer is built over the raw release store, not
claimMailStore(). ItsEnsureDomainandVerifyDomainonops/console-mailwould skip the claim mail gate when HostClaim is on. Switching to the gated store would fail today, becauseconsole-mailis not a registered app. HostClaim is off by default, so nothing changes now. Should this block the merge, or wait for a HostClaim design decision?