Build operator Easy Mode enablement action
Part of epic "Build sponsored Easy Mode app hosting" (PRD task 13412, architecture task 13413). Architecture child **C4 Enablement**. Builds on the `easy_mode` switch + `easy_mode_terms` from child C2.
## Original requirement (verbatim)
> Machines: Easy Mode orgs run their AI work on our shared node pool, placed automatically (Jaah picks a shared operator node with room); the operator picks no node.
> AI: enabling Easy Mode also enables Jaah AI for the org and grants a sponsored starting AI credit (amount set by the operator), so no card is needed until the credit runs out.
> 3. Operator enablement: turning Easy Mode on for an org, in one console action, also (a) enables Jaah AI for the org and sets it to Jaah mode, (b) grants the sponsored AI credit the operator enters, (c) makes the org eligible for automatic placement on the shared node pool. The action refuses with a clear message when no shared pool node is available or the platform AI account is not configured. Turning it off stops new Easy Mode projects; existing apps keep running until paused by the operator.
PRD decisions (binding): D5 each Easy Mode org gets ONE shared node of its own (a co-located node), placed automatically on an operator Shared pool host with room. D6 client members' web console / session terminal on that shared node is read-only. D7 the shared node is created in the same action that turns Easy Mode on; that action refuses when no Shared pool host has room. D8 one-time sponsored credit, mode `jaah`, no card needed; when spent, AI work is held (existing floor). D15 turning off stops new Easy Mode projects only; node, Jaah AI mode and remaining credit stay.
## Approach (settled by the Architect)
- Route `PUT /api/operator/orgs/{org_id}/easy-mode` body `{on: bool, credit_usd: decimal (required when on, ≥ 1.00 and above the wallet floor), sponsor_end?: date}`; operator-plane only. UI: one dialog on the customer page (new `easy-mode` tab in `apps/web/src/pages/customers/customerTabs.ts:22-31` + `CustomerPage.tsx:48-58`), showing current state, credit granted, shared node (the apps list on this tab is added by C9).
- `on: true`, ONE transaction, a refusal writes nothing:
1. `bedrock.connection(session) is None` → 409 "The Jaah AI account is not set up." (`bedrock/account.py:92-109`).
2. `hosting_provisioning.lock_pool` + `pick_shared_host` (`hosting_provisioning.py:232,239`) → none → 409 "No shared machine has room. Add capacity to the Shared pool." The tailnet refusal `vnodes._NO_TAILNET` (`vnodes.py:577`) surfaces the same way ("Machines can't join the network. Set the tailnet credential in Settings."), checked before any write.
3. `orgs.easy_mode_override = true`, `orgs.jaah_ai_enabled_override = true`, sponsor end override.
4. Ledger: `credit(kind="sponsored_credit", amount_micros, idempotency_key=f"easy-mode-credit:{org_id}")` (one-time).
5. Mode `jaah` via `token_billing.settings.set_mode` (balance now above floor; no card needed).
6. `OrgComp(kind="shared_node", end_mode="never", origin="easy_mode")` (new nullable `org_comps.origin`) and place its copy synchronously in the same transaction (`plan_bundles._place_shared` → `vnodes.place_virtual_node`, uncommitted until commit, `vnodes.py:583`). Name `<org-slug>-shared-<n>` (`plan_bundles.py:569-573`).
- `on: false` sets `easy_mode_override=false` only.
- Re-enable (org that was on before): flips `easy_mode_override` back and applies a changed `sponsor_end`; reuses the existing `easy_mode` comp and its node (a missing copy is re-placed with the same room refusal), grants NO second credit (dialog shows "Sponsored credit already granted: $X" and hides the field; a body `credit_usd` is refused 409 "Sponsored credit was already granted."), re-sets Jaah AI on + mode `jaah` only if the platform check passes.
- Ledger (pinned, `token_billing/ledger.py`): kinds `sponsored_credit` (free) and `sponsored_expiry` (debit); wallet gains `sponsored_micros` (⊂ `free_micros`; `ck_token_wallets_split` unchanged). Spend draws sponsored → other free → paid. `expire_sponsored(session, org_id) -> int` debits exactly `sponsored_micros` (bought and signup credit never removed). The Jaah AI page and wallet label the entry "Sponsored credit".
- Console read-only: in `apps/api/app/routes/console.py:257-290` both `_console_read_only` and `_console_slot_editable` return read-only when the slot's project has `projects.easy_mode = true` and the principal is not operator-plane (every tenant principal is view-only on Easy Mode project sessions; operators keep two-way).
- **Pinned:** `projects.easy_mode boolean not null default false` — this child is the SOLE owner of that migration (one migration here: `projects.easy_mode`, `org_comps.origin`, wallet `sponsored_micros`, ledger kinds). C5, C6, C8 read it; C6's create sets it true.
- Q6: align any differing dev Jaah AI platform (Bedrock) setting with prod's before running the AI checks (through the console Settings UI).
## Acceptance criteria (verbatim, BA)
- **AC-3 One action.** Turning Easy Mode on for an org is ONE operator action (one dialog, one confirm) that takes the sponsored credit amount (and optional sponsorship end) and turns on Easy Mode + hosting; no other switch is needed. → verify: dev, customer page, turn on Easy Mode for a test org; count one dialog; afterwards the org's plan terms show Easy Mode on.
- **AC-4 Enable side-effects.** After AC-3 the org: shows Jaah AI ON in mode `jaah`; its wallet shows the entered sponsored credit as a credit entry labelled as sponsored; it has no card on file; it has exactly one live shared node placed on a Shared pool host (named readably, e.g. `<org>-shared-1`), which the operator did not choose. → verify: dev, operator view of the org's Jaah AI tab and Nodes; tenant `/org/billing/jaah-ai` shows balance = credit and "no card"; node list shows one node with host on the pool. Architect addition: off → on again shows the same node and one credit entry.
- **AC-5 Readable refusals.** The enable action refuses, writing nothing (no switch change, no credit, no node), with a one-line message: when no Shared pool host has room ("No shared machine has room. Add capacity to the Shared pool."-class sentence) and when the platform AI account is not configured ("The Jaah AI account is not set up."-class sentence). → verify: on dev, with the test org, simulate each condition the Architect names (e.g. Shared pool capacity 0 / platform Bedrock knob blank in a dev-safe way) and observe the message and that the org's switch, wallet and nodes are unchanged.
- **AC-3b Off again** (server part). Turning Easy Mode off for the org removes the Mode question from the wizard and refuses new Easy Mode projects; existing projects, apps, the shared node, Jaah AI mode and remaining credit are unchanged. → verify: dev, turn off; observe wizard as AC-1; existing app URL still answers; node and wallet unchanged. (This child: off leaves node, wallet, mode untouched and the switch off; wizard/app halves are proven by C7/C9.)
- **AC-20 Read-only console.** A client member opening the console/session terminal of a session on the org's shared node gets a view-only terminal (keystrokes not delivered); an operator keeps the full terminal. → verify: dev, as client member open a running session's console, type, observe no input reaches it and a "read-only" indication; as operator, observe two-way. (This child: the rule + tests at `console.py`; the live session check needs an Easy Mode project and is re-run end to end by C7.)
- **AC-14 Sponsored AI runs** (this child's part: credit draw + hold). "… the wallet balance drops by its charge, drawing on the sponsored credit; with the balance at the floor, the next task is held with the existing \"balance\" reason and the client sees how to top up."
- **AC-1** (this child's part): with Easy Mode off, no Easy Mode / sponsored text shows on tenant org or billing pages.
All UI oracles on the dev console in a browser; copy per `docs/standards/ui-copy-standards.md`; phone width passes; fails if text truncated, overlapping, or a raw slug/id/enum shows instead of a readable label.
Credentials & reach:
- AC-1…AC-9, AC-14…AC-20, AC-22, AC-23, AC-3b (dev UI) — `me/jaah/debug/main/operator-login` (operator; switches into the test tenant, covers member role) on `app.dev.jaah.ai`; member view also via `org/console/dev-tpl-member-login` — verified — both on roster, `credential_get` ok; operator login `POST /api/auth/login` 200 from this node.
- AC-5 / AC-6 / AC-7 shared pool — dev operator API `GET /api/vnode-hosts` — verified — `pool-c-host` shared_capacity 4.
- AC-4, AC-14 Jaah AI on dev — dev platform Bedrock settings — verified — operator `GET /api/settings` on dev and prod: `platform_bedrock_source = task_role` on both, region and invoke role set on both (values not recorded). Operator answer Q6 "Copy from prod": C4 makes dev's Bedrock settings match prod's before its checks.
- AC-5 "AI account not set up" simulation — dev Settings `platform_bedrock_source` → `credential` with a blank credential for the probe, restored at once — verified (operator login can edit dev Settings); dev-only, a minute-long window in which dev Jaah AI tasks hold "Jaah account unavailable" — run at a quiet time.
- AC-5 "no room" simulation — dev `PATCH /api/vnode-hosts/{pool-c-host}` `shared_capacity` = its current used count, restored at once — verified (operator); existing nodes untouched, only new Shared placements refused during the probe.
- Live DB reads — `tools/cp_psql.sh dev` — deferred — broker refused for this task; every oracle above uses the console/API instead.
Out of scope: a separate GitHub org; a separate app apex domain; custom domains, registrar purchase and paid hosting after sponsorship (draft task 10233 stays as that later work); the always-on shared-box hosting tier. Do NOT give tenants any self-serve enable; do NOT change how non-Easy-Mode orgs' nodes, Jaah AI, wallets or consoles behave; do NOT build project assignment / re-placement (C5), hosted-app tables or routes (C6), runner tokens (C8), the sponsorship-end cron or expiry trigger (C9 calls `expire_sponsored`).
Done means:
- AC-3 — dev: one dialog, one confirm turns Easy Mode on for the test org (screenshot); the org's plan terms show Easy Mode on.
- AC-4 — dev: operator Jaah AI tab shows ON / mode Jaah; tenant `/org/billing/jaah-ai` shows balance = entered credit, an entry labelled "Sponsored credit", and no card; Nodes shows exactly one live node `<org>-shared-1` on a Shared pool host. Off → on again: same node, still one credit entry, the dialog shows "Sponsored credit already granted: $X".
- AC-5 — dev: each simulation (no room; AI account not set up) shows its one-line message; org switch, wallet and nodes unchanged afterwards (cite before/after reads); both simulated settings restored.
- AC-3b (server) — dev: turning off leaves node, wallet balance and Jaah AI mode unchanged; switch reads off.
- AC-20 (seam) — api tests: tenant principal on a slot whose project has `easy_mode=true` gets `read_only`; operator-plane principal two-way; non-Easy-Mode project unchanged. End-to-end check deferred to C7.
- AC-14 (credit) — api tests: spend draws sponsored → other free → paid; `expire_sponsored` debits exactly `sponsored_micros` and never bought/signup credit; at the floor the next task is held with the existing balance reason.
- AC-1 (part) — with Easy Mode off, no sponsored/Easy Mode text on tenant org or billing pages (screenshot).
- `./dc check quick` green; refusal paths write nothing (api test asserting no row changes).
Activity
- Draft
- Ready