Providers tab in plans and overrides
Original work item (verbatim): "Providers and their auth type is a standard plan feature. Refactor current implementations"
Operator decisions (verbatim, PRD §1):
| # | Question (short) | Answer (verbatim) |
|---|---|---|
| D1 | What should the system default + per-org Providers tab become? | "Plan grid + org override (Recommended) — operator comment: Also must be universal with custom agents" |
| D2 | Fate of the plan switch `non_claude_engines_allowed` | "Retire it, grid decides (Recommended)" |
| D3 | Operator's own orgs (ungoverned today) | "Governed by its plan/override" |
| D4 | Pairs a later build adds | "OFF until enabled (Recommended)" |
| D5 | What "universal with custom agents" means | "Agent kinds as grid rows, Each registry agent as a provider" |
| D6 | Where the grid sits | "New 'Providers' tab (Recommended)" |
| D7 | Data migration | "Preserve effective access (Recommended)" |
| D8 | Existing accounts on a now-disallowed pair | "Stop bullshit". Read as: no change from today, so keep current behaviour (never edited or deleted, refused at dispatch). |
## Architecture decisions (verbatim)
| # | Question | Answer (verbatim) | What it changes |
|---|---|---|---|
| Q1 | What does the grid's "SDK agent" row replace? `agents_enabled` gates more than running SDK agents: the Agents pages, agent CRUD, connectors, the project default agent and the task agent pick. | "All of it, retire switch" | `agents_enabled` is retired completely (it overrides PRD A2's partial reading). The `sdk` row gates every manifest agent, connectors included. The Agents pages show when any agent row (`sdk` or any `acp:*`) is ON. |
| Q2 | How does the migration keep registry access where `acp_registry_allowed` was ON? | "All OFF, operator enables" | Every `acp:*` cell starts OFF on `(default)`, on every plan and in every override. **This overrides D7/R7 for registry rows only.** Built-in pairs and the `sdk` row still keep exactly the access they had. |
| Q3 | Which registry agents appear as grid rows? | "Installable ones + stored" | A registry entry is a row when the install route would accept it, or when any plan, `(default)` or override already stores it. |
| Q4 | Delivery order | "Yes, 3 children" | Three children: C1 builds the term and the built-in cutover. C2 (agent rows) and C3 (Providers tab UI) both depend on C1 and build in parallel. |
## Requirements (PRD §3, verbatim — this child delivers the parts its scope names: R9, the UI half of R2/R5, and R10 for the new tab)
**R1 — One plan term, the provider grid.**
- What an org may run is ONE plan term in `app/plan_terms.py`: a set of allowed rows × auth types.
- It resolves the standard way: org override → plan → `(default)` → code constant.
- A plan may inherit it from `(default)`: a new `INHERITABLE` entry, e.g. `providers`.
- The code constant is today's `SEED` (Claude + API key).
- `plan_terms.py` is the single home of the resolution. `provider_auth_policy.py` may remain as the door-facing API (`is_allowed`, `require_allowed`), but it reads only the plan term.
**R2 — Rows of the grid are universal (D1, D5).**
The grid has one row per runnable engine:
- (a) each built-in provider in `agent_providers.PROVIDERS`, with that provider's `auth_kinds`;
- (b) the agent kind "SDK agent" (manifest agents), as one row;
- (c) each ACP-registry agent, as a provider row of its own, keyed by its registry id. Its auth type is the key route registry agents use: API key via ACP `authenticate`.
One resolver answers "may org O run row R with auth type K" for all three.
**R3 — Retire the old mechanisms (D1, D2, D5).** Removed, migrated first (R7):
- the system default (`system_state` `provider_auth_defaults`, Settings › Providers › Default providers, its API routes);
- the org Providers tab and `orgs.provider_auth_override`;
- the plan switch `non_claude_engines_allowed` (SWITCHES row, plan column, org override column, console twin `planSwitches.tsx`, Features-grid entry, `CONSOLE_SWITCHES`, dev seed);
- the switches the grid's agent rows replace: `agents_enabled`'s gate on running/installing SDK agents and `acp_registry_allowed`.
Columns are dropped only per the additive-migration rule: they stay until no release reads them (`docs/standards/coding-standards.md`).
**R4 — Every door reads the grid.** Every place that today reads tiers 1–4 reads the grid term instead:
- account create;
- dispatch candidate pick (`auth_pair_disallowed`);
- sign-in;
- task create/update provider pin;
- automations;
- project default provider;
- console provider pick;
- agent picker built-ins;
- SDK-agent run/install;
- ACP-registry browse/install/bind/dispatch, including the registry agent's bound key account at bind time and at dispatch.
A provider is "on the plan" iff at least one of its pairs is allowed (D2). Refusals name the row and the auth type, and point to Billing for a tenant: "<Row> with <auth type> is not on your plan. Upgrade in Billing."
**R5 — Operator orgs governed (D3).** Operator-plane orgs resolve the grid like tenants:
- an operator-plane org has no plan, so it reads `(default)` plus its own org override;
- the "not governed" carve-out in `provider_auth_policy.py` / `tenancy` is removed for this term;
- the term is overridable on operator-plane orgs (`OPERATOR_PLANE_SWITCHES` equivalent).
**R6 — New rows OFF (D4).** A pair, provider, or registry agent that a later build or a registry refresh adds is OFF on every plan, `(default)` and org until an operator turns it on. It is never ON by inference.
**R7 — Migration preserves effective access (D7).** After the migration every org — tenant, operator-plane, staging (via its parent) — can use exactly the set of built-in pairs it could use before. Computed per org as `(org override ?? system default, or all supported pairs for operator-plane) ∩ non_claude switch`. The rest of the migration:
- `(default)` = today's system default;
- each plan = that list filtered by the plan's effective Non-Claude switch, or inherits `(default)` when the filter changes nothing;
- an org whose effective set differs from its plan's gets an org override equal to its effective set today;
- SDK-agent and registry rows: ON exactly where `agents_enabled` / `acp_registry_allowed` were effectively ON;
- the migration is idempotent and reversible per the repo's migration rules.
**R8 — Existing accounts (D8).** Unchanged from today: an account whose pair the terms disallow is never edited or deleted. It is shown off-plan and never leased.
**R9 — UI, Providers tab (D6).** A new **Providers** tab beside Price / Limits / Features / Products, in both:
- the plan editor (Products › plan), with "Inherit from (default)" per the standard inherit control;
- the org's Overrides, with "Use plan" / "Override" and "Overridden — plan: X" like every other term.
It shows one grid: rows = built-ins, SDK agent, registry agents; columns = auth types. Not-applicable cells are absent, not disabled checkboxes.
On the org Overrides it is offered for operator-plane orgs too (R5). The tenant-facing account page (`AccountEditPage.tsx`) names hidden rows via the plan, not "policy", and its remedy points to the Providers override tab.
Follow `docs/standards/ui-style-guide.md` and `docs/standards/ui-copy-standards.md`.
**R10 — Docs single-homed.**
- `plan_terms.py`'s header lists the new term and drops "non-Claude engines";
- `provider_auth_policy.py`'s header is rewritten or deleted to match;
- reader docs (`apps/docs`) and router rows that mention Default providers / Providers tab / Non-Claude engines describe the present;
- no copy of the resolution rule outside its home.
## This child's scope and approach (Architecture §2, verbatim)
### C3: Providers tab in the plan editor and org Overrides (UI)
*Depends on: C1's pinned API (§1.4 items 1–3). It shows C2's rows with no change once C2 lands.*
**Scope:**
- `PlanFields` section `'providers'`.
- The `PlanEditor.tsx:80-85` tab list gets "Providers" after Products, beside Price / Limits / Features / Products.
- `OverridesTab.tsx:61-66` gets Providers on **both** the tenant and the operator-plane tab lists, mapped in `OverridesTab.tsx:72`. It reverts through `overridesForm.ts` like `products`.
- A new component `components/plans/ProviderGrid.tsx`.
**Design rules:**
- Rows are grouped into the catalogue's groups and columns are the catalogue's columns. A non-applicable cell renders **nothing**: no disabled checkbox.
- **At ≥ 640px** the grid is a table with sticky row labels. **Below 640px** each row is a stacked card: the full row name on its own line, then one labelled checkbox per auth kind. The 390px width therefore needs no horizontal scroll and never clips names.
- The registry group has a filter input when it holds more than 10 rows. Its rows show their `note`. `registry_unavailable` shows a one-line notice.
- Plan mode: the standard `plan-inherit-providers` checkbox ("(default)"). When checked, the grid shows `(default)`'s value read-only.
- Org mode: the standard `override()` frame:
- outlined, with the caption "Overridden — plan: N of M on" and a Reset button;
- a test id of the form `plan-override-providers`;
- copy that follows `docs/standards/ui-copy-standards.md` and the archetypes in `docs/standards/ui-style-guide.md`.
**Acceptance it owns:** AC1, AC2, AC3, AC9 (browser), AC4 (the Overrides-only placement), AC13.
**Tests:** `PlanFields`/`PlanEditor`/`OverridesTab` unit tests, plus one e2e at 390px and 1280px under `apps/web/e2e/`, within its budget.
### Ordering and the Finish check
- C1 merges first. C2 and C3 build in parallel after it, and either can merge first.
- Between C1 and C3 the grid is editable only through the plans/org-plan API on dev. The operator accepted this (Q4). (Planner note: superseded by the Dev-state rule in Done means — no raw dev API edits.)
- Finish verifies AC1 (registry rows present) only after C2 **and** C3 are merged.
Planner additions to C3's scope (R9 tail, R10 for the new UI):
- Retarget the `AccountEditPage.tsx` operator remedy link (C1 points it at the org's Overrides tab) to Orgs › org › Overrides › Providers (R9: "its remedy points to the Providers override tab").
- Reader docs (`apps/docs`) describe the new Providers tab in the plan editor and the org Overrides (R10: docs describe the present).
## Pinned design (Architecture §1.4, verbatim — built by C1; build against it, do not re-decide)
### 1.4 API seams
These seams are pinned. C3 builds against them, and C2 extends the catalogue they return.
1. **Plans** (`routes/billing_plans.py`):
- `PlanAdminOut.providers: list[list[str]]` is the plan's own stored value.
- `PlanCreate`/`PlanPatch.providers: Optional[list[list[str]]]`. `inherits` may contain `"providers"`.
- `_snapshot` and the `(default)` change preview include `providers`.
- `dry_run` works unchanged.
2. **Org overrides** (`routes/orgs.py`):
- `OverridesOut.providers: {value: list[list[str]], overridden: bool, plan_value: list[list[str]]}`.
- `OrgPlanIn.providers: Optional[list[list[str]]]` has the **same tri-state semantics as `products`**: absent means unchanged, `null` resets to the plan, a list sets the override.
- It is accepted for operator-plane orgs too, so it joins the `OPERATOR_PLANE_SWITCHES` analogue in `put_org_plan`.
- Writes go through `org_plan_admin` with the existing override-changed activity (`OVERRIDE_CHANGED`) and customer history.
3. **Grid catalogue**: `GET /api/provider-grid`, readable by whoever may read plans.
```json
{"columns": [{"key":"oauth","label":"OAuth"},{"key":"api_key","label":"API key"},{"key":"own","label":"Own credentials"}],
"groups": [{"key":"builtin","label":"Providers","rows":[{"key":"claude","label":"Claude","auth":["oauth","api_key"]}, ...]},
{"key":"agents","label":"Agents","rows":[{"key":"sdk","label":"SDK agent","auth":["own"]}]},
{"key":"registry","label":"ACP registry","rows":[{"key":"acp:cline","label":"Cline","auth":["api_key"],"note":null}, ...]}],
"registry_unavailable": false}
```
- C1 serves the `builtin` group only. C2 adds `agents` and `registry`.
- Registry rows are the entries that pass the **same predicate the install route uses** (extracted from `acp_registry.py:223-302` into one function, `install_refusal(entry) -> Optional[str]`), plus every `acp:*` id stored anywhere (`note: "Not in the registry"` when the live list lacks it).
- If the CDN is down, the response carries the stored ids plus `registry_unavailable: true`, and the UI says so.
4. **Identity payload** (`routes/auth.py`):
- `providers: list[list[str]]` is the principal's org's allowed cells.
- It replaces `non_claude_engines_allowed` (C1) and `agents_enabled` and `acp_registry_allowed` (C2).
- Web helper `apps/web/src/lib/providerGrid.ts`:
- `cellOn(p, row, auth)`
- `providerOn(p, row)`
- `agentsOn(p)`, which is true when `sdk` or any `acp:*` is ON
- `registryOn(p)`
- These helpers become the only console readers.
5. **Org roster `plan_switches`**: the act-as narrowing on the Agents pages now keys on the roster's `providers` (per org), read through the same `agentsOn`.
Risk (verbatim):
| A naive UI renders a wide table that scrolls horizontally or clips names at 390px (AC1 floor). | The stacked-card layout below 640px (C3). |
Credentials & reach:
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| Browser flows AC1–AC4, AC8, AC9, AC11-dev | dev control plane at `https://app.dev.jaah.ai` (the PRD's "dev.jaah.ai" does not resolve from this node; `app.dev.jaah.ai` returns 200 on `/` and `/api/health` from node h5-3); credential `me/jaah/debug/main/operator-login` (operator, can switch to tenants) | verified: on the roster, `credential_get` succeeded, read-only HTTP probe from node h5-3 |
| Tenant T browser views (AC5, AC7) | the same credential, switching to a tenant org (per its description) | verified (same probe) |
| Merge and deploy watch | `jaah-forge` (pre-authenticated per task) | verified by design (runner charter) |
Out of scope:
PRD §5 (verbatim) — do NOT do any of these:
- MUST NOT change the ACP-vs-legacy runtime switch (`app/acp_runtime.py`, Settings acp-runtime). It is an operator cutover control, not a plan term.
- MUST NOT change `jaah_ai_enabled` / the Jaah AI account lane.
- MUST NOT change other plan switches, caps, quotas, products or pricing.
- MUST NOT edit, disable or delete any existing account (D8).
- MUST NOT add new providers, auth kinds, or registry distribution rules (`acp_registry.pick_distribution`, key routes).
- MUST NOT change OAuth-first spillover or account picking beyond the pair verdict.
- MUST NOT touch the node daemon (`apps/jaahd`) except, if needed, reading `agent_providers` metadata. No node wire change.
- MUST NOT keep any of the retired mechanisms as a parallel source of truth (no "system default" fallback beside `(default)`).
- Non-runnable parts of the agent catalog (e.g. connectors, if `agents_enabled` gates them) stay behind `agents_enabled` unless the Architect shows the switch then decides nothing. See A2.
- Architecture boundary: do NOT change the API seams pinned by C1 (§1.4) or the resolver; do NOT add backend rows (child C2 supplies `agents`/`registry` groups via the catalogue — the grid renders them with no change).
- Do NOT change provider/agent gating logic or any door; only the UI, the remedy link and reader docs listed in scope.
States to cover in tests (PRD, verbatim):
| Subject | States |
|---|---|
| Term | plan own / plan inherits `(default)` / no `(default)` row (code constant) / org override / staging (parent's) / operator-plane org (no plan) |
| Pair | known+on / known+off / unknown to stored terms (off) / no longer supported by build (dropped) |
| Account | on allowed pair / on disallowed pair |
Done means:
| # | Criterion | Verify |
|---|---|---|
| AC1 | The plan editor has a **Providers** tab showing the full grid. Rows: Claude, Codex, DeepSeek, Antigravity, Pi, SDK agent, and each ACP-registry agent. Each auth type is a column. Labels are fully readable at 1280px and 390px, with no truncated row names and no horizontal page scroll. | Browser: Products › open plan P › Providers tab → observe every row and column. Resize to 390px → names readable, no page h-scroll. |
| AC2 | Plan P can inherit the grid from `(default)` or set its own. Saving persists, and a reload shows the same state. | Browser: toggle Codex+OAuth on P, Save, reload → still on. Set "Inherit" → shows `(default)`'s grid. |
| AC3 | An org's Overrides has a **Providers** tab with Use plan / Override. An override shows "Overridden — plan: …". Clearing it returns the org to the plan value. | Browser: Orgs › T › Overrides › Providers → override Pi+API key ON, save, reload → persisted with the plan value shown. Reset → follows plan. |
| AC4 | The old surfaces are gone: no Settings › Providers › Default providers, no org Providers tab, no "Non-Claude engines" in any Features grid or override, no ACP-registry or Agent catalog switch where the grid replaced it (R3). Old deep links redirect or 404 cleanly, never a blank page. | Browser: search Settings for "Default providers" → none. Open org T tabs → no "Providers" tab outside Overrides. Plan Features → no "Non-Claude engines". `rg -n "non_claude_engines\|provider_auth_override\|provider_auth_defaults" apps/ --glob '!**/migrations/**'` → no live reader. |
| AC9 | Operator-plane org is governed: turning Codex OFF in its override hides Codex for operator users exactly as for a tenant. | Browser as operator: Orgs › Operator › Overrides › Providers → Codex off → Accounts › Add offers no Codex. |
| AC13 | Gates green: quick gate and full gate, context checker. | `./dc check quick`; merge lane `./dc check full`; `python3 tools/check_context.py` → pass. |
Ownership in this child (Architecture §2 C3, verbatim): AC1, AC2, AC3, AC9 (browser), AC4 (the Overrides-only placement), AC13.
- AC4 amended oracle (Architecture §4, replaces the rg in the AC4 row): `rg -n "non_claude_engines|provider_auth_override|provider_auth_defaults" apps/ --glob '!**/migrations/**' --glob '!**/test_migration_*'` → no live reader. Plus: org T shows no "Providers" tab outside Overrides.
- R9 remedy: as operator, Accounts › Add for an org with a pair off shows the hidden-pair remedy linking to Orgs › org › Overrides › Providers — cite the link target; unit test in `AccountEditPage.add.test.tsx`.
- R10: `apps/docs` page(s) describe the plan and Overrides Providers tab; `python3 tools/check_context.py` green.
- Dev-state rule: the hub rule is "Operate Jaah only through the Jaah UI" — never change dev plans/overrides through raw API calls. Any dev value a browser oracle changes is restored to its prior value afterwards, and the restore is cited. AC2 (plan P change), AC3 (tenant T override) and AC9 (operator org Codex off) each restore the prior value and cite it.
- AC1 registry rows are verified by the Finish stage only after C2 is merged too; this child must show whatever groups the catalogue returns (builtin at least) with no code change when C2 lands.
- Quality floor (planner tightening): at 390px each row is a stacked card with the full row name on its own line and labelled checkboxes; at 1280px a table with sticky row labels; no clipped/ellipsised names, no page horizontal scroll, non-applicable cells render nothing. Cite screenshots at both widths. A grid that technically fits but truncates names or relies on h-scroll FAILS AC1.
- Tests: `PlanFields`/`PlanEditor`/`OverridesTab` unit tests plus one e2e at 390px and 1280px under `apps/web/e2e/`, within its budget.
Outcome
Providers tab in the plan editor and org Overrides (tenant + operator), new ProviderGrid (table / phone cards), remedy link to Overrides › Providers. Quick gate + lane full gate green; #6882 merged; dev deploy green; AC1–3, AC9 checked on dev and restored. Visual check done at 1280/448/390: names whole, no h-scroll. Note: the password of operator-login was exposed in the session transcript; rotate it. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)