carlos / platform Public

spec: app blueprints — set an app up from what it declares, with one operator click

Draft design, with Jelly's DEPLOY.md as the worked example. A release
carries a blueprint (inputs, derived env, generated secrets, the
email/store/schedule needs with a why for each, owner follow-ups). A setup
request is walked through the existing doors by a console pass. The one
operator click reuses the per-account store entitlement queue, and a
status page renders DNS and follow-ups with live state.

Ruling (James, 2026-09-15): a team is an app. Discussion: amadan
carlos/platform #154.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 824035a1f894974c64ba2d2cab3011ae247a5c60 parent c1d40ee
1 file changed, +556 −0
  • docs/superpowers/specs/2026-09-15-app-blueprints-design.md +556 −0

CI failed — run details

diff --git a/docs/superpowers/specs/2026-09-15-app-blueprints-design.md b/docs/superpowers/specs/2026-09-15-app-blueprints-design.md
new file mode 100644
index 000000000..bce507bf0
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-app-blueprints-design.md
@@ -0,0 +1,556 @@
+# Blueprints: set an app up from what it declares, with one operator click
+
+**Date:** 2026-09-15
+**Status:** draft for discussion. One decision is a **(ruling)** (James,
+2026-09-15). Everything else is a **(decision)** and can be overturned cheaply.
+**Discussion:** amadan `carlos/platform` #154.
+**Worked example:** Jelly (`~/projects/lazyatom/jam`, `DEPLOY.md`).
+
+Citations are against `main` at `c1d40eeb`. A citation marked *(to confirm)*
+was reported during research but not read line by line. Confirm it before
+building on it.
+
+## 1. The problem, as found
+
+Jelly's `DEPLOY.md` is an honest runbook. Every command in it has been run for
+real. It is also the argument for this spec. To stand up one team's Jelly
+takes nine steps and roughly twenty `carlos` invocations, and the runbook
+spends a paragraph on flag spelling alone (`-app` on the older verbs, `--app`
+on the newer ones). It also asks the person to:
+
+- **type the same fact several times.** `HOST` becomes the route, the domain,
+ `JAM_ORIGIN=https://$HOST` and the sending address `jam@$HOST`. Nothing ties
+ them together, and a mistyped origin breaks CSRF, cookies, magic links and
+ the WebAuthn relying party at once.
+- **mint a secret by hand** (`openssl rand -hex 32` for `JAM_INSTANCE_KEY`).
+ If a re-run of the runbook mints it again, every authenticator enrolment is
+ stranded.
+- **know which step needs someone else.** `store grant` is operator-only, and
+ the member finds that out from a 403.
+- **carry the owner's follow-up work in their head:** the CNAME, the DKIM /
+ MAIL FROM / SPF records, the Google redirect URIs, and the VAPID keypair.
+
+The whole runbook repeats for every team, because a Jelly team is one app
+(§3).
+
+What CARLOS cannot do today is let the *app* say what it needs. The release
+manifest carries exactly one kind of declared need: the closed capability
+vocabulary (`internal/caps/caps.go:104-112`, embedded at
+`internal/release/release.go:89`). Email, storage, schedules and config are
+each a separate record with a separate lifecycle, and `carlos apps create` is
+a name claim and nothing more.
+
+### What is already right, and must stay right
+
+**Every step is already a door, and each door is re-runnable.**
+
+| Step | Door | Gate | Converges |
+|---|---|---|---|
+| env / secrets | `PUT .../config` (`authcli.go:693`) | member | synchronous CAS (`apiconfig.go:279`) |
+| instances enable | `PUT .../instances-manifest` (`authcli.go:582`) | member, **no service principal** | sync, upsert (`apiinstances.go:1379`) |
+| instances create | `POST .../instances` (`authcli.go:612`) | member | desired-state record, box reconcile |
+| domains attach | `POST .../domains` (`authcli.go:653`) | member | claim at `domains/<host>.json`, box domains pass |
+| email declare/ensure/enable | `authcli.go:490-494` | member (Admin role) | enable is a sync grant walk that delivers `<PREFIX>_*` into config (`apimail.go:792`) |
+| store create / provision | `authcli.go:760-763` | member, entitlement-gated | sync grant walk that delivers `<PREFIX>_*` |
+| store grant | `authcli.go:764` | **operator** (`CARLOS_STORE_OPERATORS`) | sync grant walk |
+| schedule set | `POST .../schedules` (`authcli.go:507`) | member | `schedules.json`, box converger |
+
+**Only storage needs an operator, and the decision is already per account.**
+The self-serve storage spec (`2026-09-02-object-storage-self-serve-design.md`)
+built exactly the "applies, then an operator approves" shape. There is an
+entitlement at `store/entitlements/<sqid>.json`, an approvals page under
+`/fleet/store` (`templates/store_approvals.html`), and keymail to operators on
+apply and on decision (`internal/console/storenotify.go:17-88`). An approved
+account gets a store from `carlos store create` alone. This spec adds
+**no new authority**. The one-click approval is that page, reached from a
+setup and given the app's own explanation.
+
+**Client-side orchestration over separate doors is the house style.**
+`carlos email enable` is declare → ensure → poll verify → enable
+(`mail_cli.go:635-703`). The server refuses enable until a domain verifies
+(`apimail.go:833-835`, "verify a domain first"). The storage spec's §6 chose
+the same shape on purpose: each door answers for one outcome.
+
+**The SES sandbox is not a per-app fact.** Lifting the sending cap is a
+deployment-level AWS action. No per-app approval can stand in for it (§12).
+
+## 2. The shape, in one paragraph
+
+A release may carry a **blueprint**: inputs to ask for, values derived from
+them, secrets to generate, and the email, storage, schedules and follow-up
+work the app needs. **Setting up** a new app from a blueprint is one form (or
+one CLI command). It becomes a **setup request**, and a console pass walks the
+request through the existing doors, step by step and resumably. A step that
+needs authority waits on the existing approval for that authority, and
+everything else carries on. What the owner still has to do, such as DNS
+records and third-party credentials, is rendered as a checklist with live
+state from the same record.
+
+## 3. Rulings and decisions
+
+- **(ruling, James 2026-09-15) A team is an app.** Email (`email.json`),
+ storage (`store.json`), schedules (`schedules.json`) and releases are all
+ per app. Only config can be split below the app, via environments
+ (`provision.go:82-86`). Making email or storage per instance would be a much
+ larger change for no gain here. One Jelly team = one app, and "create an
+ instance of Jelly" means "set up a new app from Jelly's blueprint".
+- **(decision) The blueprint rides in the release manifest**, as an additive
+ field beside `capabilities`. What an app needs belongs to its version: the
+ release that starts using object storage is the release that declares it.
+ The alternatives were a file read only at setup time, which drifts from the
+ code, and a console-registered template, which is a second thing to publish.
+- **(decision) Approval covers only what needs authority.** A setup whose
+ account is already approved for storage asks no one. There is no per-setup
+ policy gate. If one is wanted later, it is an account setting, not a
+ blueprint field.
+- **(decision) Phase 1 is same-account only** (§8). The Jelly releases and
+ the team apps live in one account.
+
+## 4. The blueprint
+
+### Source and wire
+
+**(decision) Source file: `.carlos/blueprint.json`**, beside the existing
+`.carlos/config`. It is JSON because the manifest is JSON, `.carlos/config` is
+JSON, and the platform needs no new parser.
+
+`carlos ship` reads the file if it exists and embeds it as `blueprint` in the
+release manifest (`apps/<sqid>/<app>/releases/<v>/manifest.json`,
+`release.go:196`). Absent means no blueprint, and every existing release
+parses unchanged.
+
+> **Implementation gate:** list every parser of `manifest.json`, not every
+> caller of `Ship`: `vet.Check`, adoption's `backfillManifest`
+> (`adopt.go:945`), the console's release views, and any hand-rolled CLI
+> struct. Confirm that each tolerates an unknown top-level field before the
+> field lands. `caps.Set` decodes strictly (`caps.go:188`); the blueprint must
+> not be nested anywhere a strict decoder reads.
+
+### Shape
+
+```json
+{
+ "schema": 1,
+ "title": "Jelly",
+ "summary": "A shared team inbox. One app per team.",
+ "inputs": [
+ {"name": "SITE", "prompt": "Team name, shown on the sign-in page", "required": true},
+ {"name": "HOST", "prompt": "Public hostname", "kind": "hostname", "required": true},
+ {"name": "FROM", "prompt": "Address mail is sent from", "kind": "email", "default": "jelly@{{HOST}}"}
+ ],
+ "domain": {"host": "{{HOST}}"},
+ "env": {
+ "JAM_SITE": "{{SITE}}",
+ "JAM_ORIGIN": "https://{{HOST}}"
+ },
+ "secrets": {
+ "JAM_INSTANCE_KEY": {"generate": "hex:32"},
+ "JAM_VAPID_PRIVATE": {"generate": "vapid", "public_env": "JAM_VAPID_PUBLIC"}
+ },
+ "email": {
+ "env_prefix": "JAM",
+ "from": "{{FROM}}",
+ "why": "Sign-in links, invitations and notifications. Without it, sign-in links are only logged."
+ },
+ "store": {
+ "required": false,
+ "why": "Attachments. Without it they live beside the database and are not replicated with it."
+ },
+ "schedules": [
+ {"name": "sweep", "every": "5m", "path": "/jobs/sweep",
+ "why": "Snooze wake-ups, retries, Gmail history sync and import resumption."}
+ ],
+ "followups": [
+ {
+ "title": "Google sign-in and Gmail",
+ "optional": true,
+ "text": "Create one Web OAuth client with both redirect URIs, then paste its id and secret.",
+ "values": {
+ "Redirect URI (sign-in)": "https://{{HOST}}/auth/google/callback",
+ "Redirect URI (Gmail)": "https://{{HOST}}/oauth2/gmail/connect",
+ "JavaScript origin": "https://{{HOST}}"
+ },
+ "secrets": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"]
+ },
+ {"title": "Sentry", "optional": true, "secrets": ["JAM_SENTRY_DSN"]}
+ ]
+}
+```
+
+### Rules
+
+- **Templates are substitution, not a language.** `{{NAME}}` names an input
+ or one of a closed set of built-ins: `{{APP}}`, `{{ACCOUNT}}`,
+ `{{PLATFORM_HOST}}`. There are no expressions, filters or conditionals. A
+ reference that resolves to nothing is refused at ship, not at setup.
+- **Input kinds are a closed vocabulary**: `text` (default), `hostname`,
+ `email`. Validation is the platform's own, and a hostname input is checked
+ with the rule `domains attach` uses.
+- **Generated secrets are generated only when absent.** A re-run never
+ rotates. This is the property `JAM_INSTANCE_KEY` needs, since changing it
+ strands every authenticator enrolment. Kinds are a closed vocabulary:
+ `hex:N`, `base64:N`, and `vapid` (a P-256 keypair: the private half as the
+ named secret, the public half as the env var in `public_env`). A generated
+ value is never displayed and never written to the setup request.
+- **Collision check at ship.** No `env`/`secrets` name may be one the
+ platform delivers. With `email.env_prefix = "JAM"`, the email walk writes
+ `JAM_HOST`, `JAM_PORT`, `JAM_USER`, `JAM_PASS` and `JAM_FROM`
+ (`internal/mail/walk.go:657,689`), so a blueprint that also sets `JAM_HOST`
+ is refused. The same applies to the store prefix.
+- **`why` is required on `email`, `store` and every schedule.** It is the
+ sentence the operator reads (§7) and the one the member reads when a step
+ waits.
+- **`schema` is checked like every other record's.** An unknown schema is
+ refused at setup with "this release's blueprint is newer than this
+ console", never half-applied.
+
+### Origin timing
+
+**(decision) `env` values are final from the start.** Jam's `DEPLOY.md` is
+explicit that moving `JAM_ORIGIN` strands the old host's POSTs, and a passkey
+registered against the platform hostname would not survive the move either.
+So the default is the one DEPLOY.md recommends: the origin is the custom host
+from the first boot. The instance is reachable once DNS and the certificate
+land. §9 shows that wait as a step with a state, not as silence. Serving on
+the platform host first and swapping later is an open question (§13), not a
+default.
+
+## 5. The setup request
+
+**(decision) Location: `setup/requests/<sqid>/<app>.json`**, console-only. It
+is deliberately not under `apps/` or `accounts/`, both of which are inside the
+fleet read ACL (`internal/edge/storedoor.go:365-370`). The storage spec's §4
+reasoning carries over: the operator's decision belongs to the deployment,
+not to the tenant's boxes.
+
+> **Implementation gate:** confirm by test that `setup/` is unreachable
+> through the edge store door (`aclRead`), exactly as the storage spec gated
+> `store/entitlements/`.
+
+```go
+const RequestSchema = 1
+
+// Request is setup/requests/<sqid>/<app>.json: one app set up from one
+// blueprint. The blocks are written by different actors: the member who
+// submits and the pass that walks it.
+type Request struct {
+ Schema int `json:"schema"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Source Source `json:"source"`
+ Inputs map[string]string `json:"inputs"` // never a secret
+ Submitted Submitted `json:"submitted"`
+ Steps []Step `json:"steps,omitempty"` // written by the pass
+ Cancelled *Submitted `json:"cancelled,omitempty"`
+}
+
+// Source pins the blueprint that was rendered. A later release's
+// blueprint never changes a setup already submitted.
+type Source struct {
+ App string `json:"app"` // the app whose release carries the blueprint
+ Channel string `json:"channel"` // what the new app's instance follows
+ Version string `json:"version"` // the release whose blueprint was rendered
+}
+
+type Submitted struct {
+ By string `json:"by"`
+ At time.Time `json:"at"`
+}
+
+type Step struct {
+ Name string `json:"name"` // "config", "instance", "domain", "email", "store", "schedules"
+ State string `json:"state"` // "done", "waiting", "failed"
+ Detail string `json:"detail,omitempty"` // a fixed sentence; never a backend error
+ Since time.Time `json:"since"`
+}
+```
+
+**(decision) There is no approval block on the request.** The only authority
+a Jelly setup can need is the account's storage entitlement, and that already
+has a record, a queue, a notifier and a decision (§7). A second decision
+record for the same authority would mean two answers to one question.
+
+**(decision) Steps are a cache of observed state, not the truth.** Before
+acting, each step reads the primitive it drives: `email.json`, `store.json`,
+the instance record, the domain claim. So an out-of-band `carlos email enable`
+counts as done, and a record deleted by hand is noticed. The same reasoning as
+`objstore.Record`'s derived state applies: a stored state string can disagree
+with the thing it describes.
+
+## 6. Authority
+
+| Verb | Who | Gate |
+|---|---|---|
+| plan | member | read-only; resolves the source release |
+| start / cancel | member, **person only** | the union of every door the walk drives |
+| status | member | read |
+| approve storage | deployment operator | unchanged storage-entitlement gates |
+
+**(decision) `start` is closed to service principals** (`svcrole.Set{}`),
+because the walk drives `PUT .../instances-manifest`, which admits none
+(`authcli.go:582`). A setup must not become the second door to a door that
+refuses a credential.
+
+**(decision) The walk acts as the submitter, and re-checks every time it
+runs.** Each step runs the gate of the door it stands in for, against
+`Submitted.By`'s current membership and role. A member removed from the
+account halts their setups at the next step, with "the person who started
+this setup is no longer a member". The walk never holds more authority than
+the submitter held when the step ran.
+
+**(decision) Approval stays the storage entitlement**, with its existing
+two-part operator gate (fleet bit **and** `CARLOS_STORE_OPERATORS`; storage
+spec §5). Nothing about the operator population changes.
+
+## 7. The one-click approval
+
+When `start` finds that the blueprint declares `store` and the account has no
+approved entitlement, it applies on the owner's behalf. It writes
+`Applied.Note` from the blueprint's `store.why`, prefixed with what asked:
+"Setting up `acme` from Jelly (`jam/jelly@edge`, release `51cfe82`)". It then
+sends the existing `notifyStoreApplied` email.
+
+> **Open (§13):** the storage spec rules that only the account **owner** may
+> apply. A setup started by a non-owner admin either stops at "an owner must
+> apply for object storage", or the rule is widened for this path. This spec
+> does not widen it.
+
+The `/fleet/store` row for such an application gains:
+
+- the blueprint's `title` and `summary`,
+- every declared need with its `why`, including the ones that need no approval,
+ so the operator sees the whole ask ("email, storage and a 5-minute
+ schedule"),
+- a link to the setup's status page,
+- the same **Approve** / **Decline** buttons, posting to the same doors.
+
+Approval is per account, per the storage spec's ruling. For Jelly that means
+**one click, once**: every later team app in the `jam` account gets its store
+without asking anyone. That is the point, and the queue says so beside the
+button ("Approving lets every app in `jam` create object storage").
+
+When the decision lands, the pass carries on. A decline marks the `store`
+step `failed` with the decline reason, which the storage spec already lets a
+person read.
+
+**(decision) `required` decides what waits.** A need with `required: true`
+that is not yet approved holds **instance creation**, so the app never boots
+without it. A need with `required: false` holds only its own step. Jam's
+store is `false`, because blobs fall back to local disk. So a Jelly setup
+comes up, signs people in and sends mail while the approval is outstanding,
+and the status page says, in the blueprint's words, what is at risk until it
+lands.
+
+## 8. Where the new app's releases come from
+
+A team app has no releases of its own, and the Jelly binary is shipped once,
+to the Jelly app. There are two ways to bridge that, and this spec phases
+them.
+
+**Phase 2: copy on setup (no box change).** `start` copies the pinned source
+version into the new app. The console reads the source manifest and
+artifacts and writes them under the new app's `releases/`, then promotes on
+the new app's channel. Artifacts are content-addressed and the pointer is
+signed console-side, so this is a `Ship` + `Promote` performed by the console.
+The cost is that each later Jelly release must be copied to every team app.
+`carlos setup sync --from jelly` does that fan-out and is honest about being a
+stopgap.
+
+**Phase 4: the instance record names a release source.** An additive
+`release_app` field on `apps/<sqid>/<app>/instances/<host>.json`. Adoption
+groups routes by `(account, app, channel)` (`adopt.go:343`), reads the signed
+pointer for that group (`adopt.go:347`, verified against the app it names in
+`release.go:393`), and fetches artifacts by `ArtifactKey(acct, app, ...)`
+(`adopt.go:1362`). With a source, the pointer and artifact reads use
+`release_app` and everything else stays on `app`: config bundle, store,
+email, schedules, logs.
+
+Phase 4 pays for these, and they are why it is not phase 2:
+
+- **The registry route needs the column**, and the adoption-of-existing-routes
+ shape match (AGENTS.md, "A route's Channel and Environment belong to its
+ instance record") must treat it as a column the record expresses. A route
+ that carries `release_app` with a record that doesn't must be refused, not
+ adopted.
+- **Bake holds are keyed by `(account, app)`** (`adopt.go:719,761`). A
+ source-following route's holds belong to the source's pipeline, and the
+ `FirstSeen.Holds` ratchet must not reset because the app name changed.
+- **The consumer list is everything that parses an instance record**, not
+ everything that writes one.
+- **Same account only.** A box reads `apps/<its own sqid>/**` and no other
+ account's apps (`storedoor.go:369`). Letting account B's apps follow
+ account A's releases is a read-ACL widening. This repo has paid for
+ "absence of a grant" reasoning before (AGENTS.md, object-storage tagging),
+ so cross-account is its own spec (§12).
+
+## 9. The walk
+
+**(decision) A console pass, not an in-memory job.** `systemJob`
+(`apisystemupdate.go:42-51`) lives in console memory and does not survive a
+restart. A setup that waits hours on SES cannot live there. The pass lists
+`setup/requests/`, and for each request without `Cancelled` it runs every
+step that is not `done`, in order, each through the function behind its door.
+
+**(decision) It holds the per-`(account, app)` store-walk mutex** from the
+storage spec's §9, around the store step. *(To confirm: that mutex has
+landed. The storage spec made it a prerequisite and names the concurrency bug
+it closes. If it has not landed, it lands before the setup pass does.)*
+
+| # | Step | Does | Waits on |
+|---|---|---|---|
+| 0 | app | claims the name; refuses an existing app that already has releases, instances or config ("set up only creates apps") | — |
+| 1 | release | phase 2: copies and promotes the pinned version (§8) | — |
+| 2 | config | renders `env`; generates absent `secrets`; one config write | — |
+| 3 | email | declare (`env_prefix`, `from`) → ensure the sending domain → record its DNS set for §10 → poll verify → enable | DNS at the sending domain |
+| 4 | store | create → provision | the account entitlement (§7) |
+| 5 | schedules | sets each declared schedule | — |
+| 6 | instance | instances enable → create at `{{PLATFORM_HOST}}` on `Source.Channel` | any `required` need |
+| 7 | domain | domains attach `domain.host` | DNS at the host, then the certificate |
+| 8 | restart | if steps 3 or 4 delivered config after step 6's instance went live, the app-wide restart | — |
+
+Steps 3, 4 and 7 wait **in parallel with** the rest. Each is independent once
+the app exists, and nothing about sending mail should wait on a storage
+approval.
+
+**Waiting has a state and a clock.** A waiting step shows its `Detail` and
+`Since`: "SES has not confirmed DKIM yet (2h 10m)". **(decision)** Give up
+after 72 hours, with `failed` and the same named reason `email enable` gives
+when its wait gives up. A `failed` step is retried by `carlos setup start`
+on the same app (the storage spec's "re-run resumes" property, lifted a
+level). SES has taken ~1.5h on correct DNS before (`DEPLOY.md`, step 4), so
+72h is patient, not tight.
+
+**Errors are fixed sentences.** A step's `Detail` never carries a backend
+error. The pairing each door already uses (log the detail, answer a sentence)
+applies to what the pass writes.
+
+**Serial.** Every step whose door bumps the serial today still bumps it,
+through the same call.
+
+## 10. What the owner still has to do
+
+`/a/<sqid>/apps/<app>/setup` and `carlos setup status` render one page from
+the request plus the primitives' live state:
+
+1. **Progress.** Each step, its state, and for a waiting step what it waits
+ on.
+2. **DNS to publish.** One table: the host's CNAME at `edge.oncarlos.com`,
+ and the sending domain's DKIM CNAMEs, MAIL FROM and SPF records as
+ `domains ensure` returned them. Each row carries its live check state from
+ the same sources `domains list` (`domainstate.go:20-63`) and `email status`
+ read. A row that is correct says so.
+3. **Follow-ups.** Each blueprint `followups` entry with its `values`
+ rendered. Each `secrets` name is a field that writes through the ordinary
+ config door as the viewing member, and the entry reads as done once every
+ named variable is present. Optional entries say so and never block
+ "setup complete".
+4. **Waiting on an operator.** When step 4 is waiting, the account's
+ entitlement state in the storage spec's wording ("applied by … on …,
+ waiting for a deployment operator"), and the blueprint's `why`.
+
+**Setup is complete** when every non-optional step is `done` and the domain
+is `live`. The page then says so and links to the app.
+
+## 11. Surface
+
+### CLI
+
+```
+carlos setup plan --account <a> --app <new> --from <app>[@<channel>] [--set NAME=value ...]
+carlos setup start --account <a> --app <new> --from <app>[@<channel>] [--set NAME=value ...]
+carlos setup status --account <a> --app <new>
+carlos setup cancel --account <a> --app <new>
+```
+
+- `plan` renders the blueprint against the inputs and prints what `start`
+ would do: every variable name, every declared need with its `why`, and
+ which steps will wait on whom. It writes nothing. On a TTY it prompts for
+ missing inputs and shows each input's `default` rendered. Off a TTY a
+ missing required input is an error naming it.
+- `start` is `plan`, then submit. It prints the status URL and exits. It does
+ not hold the terminal through a 72-hour wait.
+- `cancel` stops the pass. It destroys nothing: what exists stays, and is
+ removed with the ordinary verbs.
+- New verbs, so `--app`/`--account` spelling. **Each needs a heading in
+ `docs/site/cli.md`** (AGENTS.md; `internal/docsite` fails the gate
+ otherwise).
+
+### Console
+
+- **`/apps/new`** (`templates/app_new.html`) gains "or set up from a
+ blueprint". It lists apps in this account whose channels carry a release
+ with a blueprint, then shows the rendered form: inputs with prompts and
+ defaults, the declared needs with their `why`, and which will need an
+ operator.
+- **`/a/<sqid>/apps/<app>/setup`** is §10.
+- **`/fleet/store`** gains the setup context of §7.
+
+## 12. Non-goals
+
+- **The SES sandbox and sending cap.** These are deployment-wide AWS state.
+ At most, the email step's waiting text can say that a fresh deployment only
+ delivers to verified recipients.
+- **Minting third-party credentials.** Google exposes no API for a web OAuth
+ client. `followups` renders the exact values, and that is the whole job.
+- **Cross-account setup** (account B setting up an app from account A's
+ release). Read ACL, billing and who may approve all change. That is its own
+ spec.
+- **Per-instance email or storage.** Ruled out in §3.
+- **Re-applying a blueprint to an existing app.** Setup creates apps. Later
+ changes are ordinary verbs, and a release whose blueprint adds a need is an
+ open question (§13).
+- **Teardown.** `cancel` stops. It does not remove anything.
+
+## 13. Open questions
+
+1. **Owner-only apply** (§7). Should a setup started by an admin be able to
+ apply for the account's storage, given that the blueprint's `why` is on the
+ application and an operator still decides?
+2. **A release that adds a need.** When Jelly v2's blueprint first declares
+ `store`, do existing team apps learn about it? A "this app's release
+ declares needs it does not have" line on `apps` status is cheap, and
+ applying it automatically is not.
+3. **Serve on the platform host first?** A blueprint flag that starts `env`
+ values referencing a `{{SERVING_HOST}}` built-in on the platform host and
+ rewrites them when the domain goes `live`. It is useful for apps without
+ Jam's single-origin constraint, and dangerous for apps with passkeys.
+4. **Phase 2 copy vs going straight to phase 4.** The copy fan-out is
+ operationally ugly, but it touches no box code. Is it worth shipping, or
+ is it better to wait for the release source?
+5. **Pipelines.** Should a blueprint be able to ask for a ladder
+ (`carlos pipeline init --template ...`), or does every setup app start on
+ the one-channel `edge` pipeline new apps get today?
+6. **Inbound mail.** Jam reads `JAM_RECEIVING_DOMAIN` and
+ `JAM_SES_HOOK_SECRET` (`cmd/jam/main.go:116-117`), and `DEPLOY.md` does not
+ mention them. If inbound mail is a platform primitive-to-be, it is a
+ future `needs` entry. Until then it is a follow-up.
+
+## 14. Phasing
+
+1. **Blueprint package and ship-time validation.** Schema, template
+ resolution, input kinds, generated-secret kinds, the collision check, and
+ `carlos setup plan`. Nothing is written except the additive manifest
+ field.
+2. **`carlos setup start` without a server pass**, client-side orchestration
+ over the existing doors as `email enable` does it, with phase 2 release
+ copy. This proves step order, resumability and the owner checklist
+ against real SES before any new record exists. It holds the terminal, and
+ that is the known limitation.
+3. **Setup request, console pass, status page, `/apps/new` form, and
+ `/fleet/store` context.** The CLI's `start` switches to submitting.
+4. **Release source on instance records** (§8).
+
+## Appendix: Jelly's `DEPLOY.md`, step by step
+
+| DEPLOY.md | Under this spec |
+|---|---|
+| 1. Build and ship | `make release` + `carlos ship` to the Jelly app, once per release, not per team |
+| 1. Config and secrets | step 2: `JAM_SITE`, `JAM_ORIGIN` rendered; `JAM_INSTANCE_KEY` generated once |
+| 2. The hostname | steps 6–7; the CNAME is a row in §10 |
+| 3. Provision email | step 3, `from` defaulted to `jelly@{{HOST}}` and editable |
+| 4. Publish the email DNS | rows in §10 with live state |
+| 5. Object storage | step 4 + the one-click account approval (§7), once per account |
+| 5. The clock | step 5 |
+| 6. Google credentials | a follow-up with the redirect URIs rendered; VAPID generated |
+| 7–8. First sign-in, email test | unchanged; the status page links to `/signin` when complete |
+| 9. Finish in the app | unchanged |