# 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 console_mail_from 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.

2. **`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.

3. **`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.

4. **`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.

5. **`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

6. **`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.

7. **`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

8. **`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

9. 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:

```go
	// 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?
