carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.sealEnvelope and Store.CountPendingInvites run 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 root carlos binary links neither internal/console nor internal/mail. No check pinned that before this ruling, so one is added: TestRootBinaryLinksNoConsoleOrMail (internal/provision/root_binary_deps_test.go:18) runs go list -deps . for linux, darwin and windows and fails if either package, or anything under it, appears. It skips when go is 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 for apiSession.getJSON (plan14-root-reads-carlos-run.tsv:126-127). Its one read, crypto/rand.Read for the envelope id, moved unchanged into sealEnvelope, the same class.
  • R3. Out of census scope. The ses:SendEmail grant 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 sets console_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-delivery marker is now census-ruling-console-invite-delivery in every row, as box-publish-record and the step 6 overlays did, and the overlay header names the ruling. The @legacy fingerprints were recomputed for the new basis text. The seal, recomputed with the overlay loaded after box-publish-record, is fcdf9de0ba39db40d6bc7d1d2137d4f40d8248df8d286a380b2eae4e8b31d7a3, 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_CLAIM on, the console mailer's EnsureDomain and VerifyDomain must 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.Start runs 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 inside carlos-edge.service's delegated cgroup (internal/host/units/carlos-edge.service:28 User=carlos, :64 "The agent and its flat children", :95-101 Delegate=). That unit has no IPAddressDeny= 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:4 and :28), but each template carries a unit-level IPAddressDeny= 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:40 is DynamicUser=yes and :69 is IPAddressDeny=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 granted host_role gets a drop-in with IPAddressAllow=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, per carlos-app@.service:10-11). On such a box a host_role unit tenant reaches IMDS too.
  • IMDS options. aws_instance.host (infra/modules/region-host/main.tf:482-511) sets no metadata_options, so http_tokens and http_put_response_hop_limit take the AMI's and the account's defaults (the scanner host sets http_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.do returns a typed keymailError for a non-200 answer. SendApp's envelope sealing moves, unchanged, into a new Keymail.sealEnvelope, which SendApp and the new SendFederated (keymailfed.go) share.
  • internal/console/server.go: NewServer sets the invite delivery fields. handleInvite is rewritten, but it has no read site of its own.
  • internal/console/store.go: new Store.CountPendingInvites.
  • cmd/console/main.go: serve() builds the console mailer (consoleMailer) and starts its identity ensure in a goroutine.
  • infra/modules/region-host: an optional ses:SendEmail grant 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.go gains 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".

  1. cmd/console/main.go:serve: data-literal@3446,4547,4784, digest a9ecdd3b…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 the CARLOS_REGISTRY default /data/registry.db (main.go:333) and the CONSOLE_DB default /data/console/console.db, used twice (main.go:352 and 355). The other changes are ConsoleMail: consoleMail in Config, and a goroutine bounded at two minutes that runs ensureConsoleMail (main.go:457). The mailer is built over the release store, provisioner and DNS writer that serve already holds. consoleMailer itself (main.go:995) has no read site. Proposed: accepted-legacy, the console.
  1. internal/console/server.go:NewServer: data-literal@9108, digest 4d82a686…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 sets consoleMail, inviteTimeout and inviteKeymailShare (server.go:1309), and binds sendFederated beside sendApp (server.go:1329). No goroutine is started. The record-write census re-digested the same unit in 05cfbec0. Proposed: accepted-legacy, the console.
  1. internal/console/keymail.go:Keymail.do: file:ReadAll@594, which is the same site as on main. Only the digest moved, to e2f193e4…e7be. The read is io.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 that IsKeymailBlocked can classify the answer. Proposed: accepted-legacy, the console.
  1. infra/modules/region-host/main.tf:external: digest 6ae47ec3…9c5b. Adds aws_iam_role_policy.host_console_mail (main.tf:702). It has count 0 unless console_mail_from is set. It grants ses:SendEmail on the one sending identity, the carlos-bab configuration set and its tenants, on condition that ses:FromAddress equals console_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.
  1. infra/modules/region-host/variables.tf:external: digest ffdad585…250e. Adds console_mail_from (variables.tf:221, validated as one lowercase address) and console_mail_region (variables.tf:231). Both are empty by default. Proposed: accepted-legacy, tofu on an operator's machine.

New units

  1. internal/console/keymail.go:Keymail.sealEnvelope: "unclassified root read … file:Read@387", digest b140fde1…7658. file:Read@387 is rand.Read(idb) at keymail.go:258, and rand is crypto/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 in SendApp as 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.
  1. internal/console/store.go:Store.CountPendingInvites: "unclassified root read … file:QueryRow@102", digest 1733078d…18e5. One SQLite QueryRow (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 siblings Store.PendingInvites, CreateInvite and RevokeInvite are accepted-legacy (J4 root-input) on main, and so are all 84 store.go rows. Proposed: accepted-legacy, the console.

Retired unit

  1. 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 into sealEnvelope, 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: @remove and @removed … H3, the form census ruling carlos-run used for apiSession.getJSON and ensureExe. The loader accepts an H3 removal only with a census-ruling- basis, so the row reads census-ruling-console-invite-delivery: … (R2). A removal is not part of the seal.

Source envelopes

  1. The four "changed root source envelope" rows are re-pinned: - cmd/console to 2af70021…c0c6 - infra/modules/region-host to 82e90b10…56e7 - internal/console to 18fc22e0…5756 - internal/mail to c45dd340…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

  1. 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?
  2. Should Keymail.SendApp be retired by an H3 census-ruling removal (the carlos-run getJSON form), now that its only read lives in sealEnvelope?
  3. The ses:SendEmail grant is on the shared host role. Any process that holds the box's role credentials (root, the agent or the console) can send as console_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?
  4. Keep the PROPOSED-console-invite-delivery marker in the frozen bases (seal 5ebadd04…), or rewrite it to a census-ruling- tag (seal fcdf9de0…)?
  5. 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(). Its EnsureDomain and VerifyDomain on ops/console-mail would skip the claim mail gate when HostClaim is on. Switching to the gated store would fail today, because console-mail is 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?