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>
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 | |