"TEN plan-change HTTP host — POST/GET plan + history TX trigger + plan_tier TEXT→enum"
TASK-TEN-203: TEN plan-change HTTP host
Summary
Wire the shipped cyberos-ten pure decision substrate into the auth admin axum host: POST / GET /v1/admin/tenants/{id}/plan, persist plan changes with a same-TX history row enforced by a Postgres trigger, and cut over auth tenants.plan_tier from TEXT (+sandbox CHECK) to the closed plan_tier enum.
Problem
TASK-TEN-002 landed library + additive migrations only (decide_plan_change, PlanShow, enum CREATE TYPE). There is no HTTP route, no history TX trigger, and auth still stores plan_tier as TEXT with a CHECK that includes sandbox. Operators cannot change plans via API; billing (TASK-TEN-003) has no host surface to call.
Proposed Solution
- Add
services/auth/src/plan_admin.rswith:
GET /v1/admin/tenants/:tenant_id/plan→ JSON fromPlanShow(+effective_sincewhen column present, elsecreated_at)POST /v1/admin/tenants/:tenant_id/planbody{ target_tier, effective, acknowledge_data_loss?, reason? }→ buildPlanChangeRequestfrom JWT claims + DB →decide_plan_change→ one TX: INSERTtenant_plan_historythen UPDATEtenants.plan_tier
- Role gate:
tenant-adminfor own tenant, or founder role for any; founder-tenant short-circuit already indecide_plan_change. - Auth migration
0034_plan_tier_enum_and_history.sql: create enums if missing; relocate anysandboxrows tostarter; drop TEXT CHECK; cast column toplan_tier; ensureis_founder_tenant/ history table; installrequire_plan_historyBEFORE UPDATE trigger (P0301). - Auth models: validate create-tenant
plan_tieragainst the closed 3-value set (rejectsandboxat API). - Depend on
cyberos-tenfromcyberos-auth.
Error mapping: Forbidden/FounderImmutable → 403; SameTier → 409 no_change; DowngradeViolation → 409 downgrade_violation.
Alternatives Considered
- New
cyberos-tenHTTP binary. Rejected for this slice: tenants + JWT already live in auth; travel-policy is the established admin nest pattern. - Defer TEXT→enum to TASK-TEN-001. Rejected: host UPDATE must write the enum type; leaving TEXT would block the trigger and sqlx typing.
- Full TEN-002 residual (rate-limit, dry-run, history GET, founder override route, metering plan_change event). Rejected: explicit Out of scope for host-a; ledgered below.
Success Metrics
- Primary: authenticated
tenant-admincan upgrade Starter→Team via POST and see Team caps on GET; history row exists; bare UPDATE without history fails at DB. - Guardrail: existing auth create/list tenant tests still pass; no
sandboxaccepted at create API.
Scope
In scope
- Auth routes GET/POST plan; TX persist; P0301 trigger; TEXT→enum cutover; create-path allowlist; unit/integration tests for decision wiring + HTTP error mapping (Postgres tests when
DATABASE_URLavailable, otherwise library + handler unit tests with mocked decide path).
Out of scope / Non-Goals
- 24h plan-change rate limit, dry-run query,
GET …/plan/history,DELETE …/plan/scheduled, founder override separate route (TEN-002 §1 #14–#23 residuals). - Memory audit emit
ten.plan_changed(follow-on once memory bridge pattern chosen for auth plan path). - Metering synthetic
plan_changeevent (DEC-783) — needs TEN-204 recorder surface. - Auth middleware
api_callsemit; INV Wise host; status hub regen.
Dependencies
TASK-TEN-002done (library substrate).- Auth JWT +
tenant-adminrole catalogue (TASK-AUTH-101).
AI Authorship Disclosure
Generated then reviewed against as-built services/ten + services/auth deep map (2026-07-26).
Acceptance Criteria
- GET plan — JWT
tenant-adminfor tenant T receives 200 withtier,capsmatchingcaps_for(tier), andis_founder_tenant. - POST upgrade — Starter→Team with
effective: immediatereturns 200;tenants.plan_tieristeam; onetenant_plan_historyrow with positiveproration_amount_centswhen mid-period. - Downgrade violation — usage above target seats without
acknowledge_data_loss→ 409downgrade_violation. - Founder lock — non-founder actor against
is_founder_tenant=true→ 403founder_tenant_plan_immutable. - Same tier — POST with current tier → 409
no_change. - History trigger —
UPDATE tenants SET plan_tier=…without prior history INSERT in same TX → SQLSTATE matching P0301 / raise exception. - Enum cutover —
plan_tiercolumn type is enum; create-tenant rejectingsandboxwith 400; cardinality of enum = 3. - Auth dep —
cyberos-authdepends oncyberos-ten; handlers calldecide_plan_change(not a reimplemented decision tree).
Verification
cd services
cargo test -p cyberos-auth --test plan_change_http_test -- --test-threads=1
cargo test -p cyberos-ten -- --test-threads=1
bash .cyberos/cuo/gates/run-gates.sh
Failure Modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Missing history INSERT | P0301 trigger | TX abort | Handler always INSERT then UPDATE |
| Sandbox row at migrate | Migrate relocate to starter | Enum cast succeeds | Log count relocated |
| Stale TEXT in Rust | Compile / sqlx | Build fail | Use PlanTier / String as_str |
| Concurrent plan POSTs | Unique race on history | Last writer wins per TX | Rate-limit later |
| JWT wrong tenant | Role gate | 403 | Client retry |
| DB down | sqlx error | 500 | Retry |
| Invalid target_tier JSON | Serde / parse | 400 | Client fix |
| Downgrade ack false | decide_plan_change | 409 | Ack or reduce usage |
| Founder immutable | decide_plan_change | 403 | Founder path later |
| Migration order vs TEN 0004 | IF NOT EXISTS / DO blocks | Idempotent | Re-run migrate |
End of TASK-TEN-203.
Audit
§1 — Verdict summary
Focused residual host task: wires TEN-002 library into auth admin routes + enum cutover. 8 ACs, 10 failure-mode rows, explicit Out of scope for TEN-002 residuals not in host-a.
§2 — Findings (all resolved)
ISS-001 — Host choice ambiguous
Could have been a new ten binary. Resolved: Proposed Solution #1 + Alternatives; AC #8 auth dep.
ISS-002 — Sandbox cutover underspecified
TEXT CHECK includes sandbox. Resolved: migrate relocates sandbox→starter; AC #7.
ISS-003 — Missing P0301
TEN-002 deferred trigger. Resolved: Scope + AC #6 + failure table.
ISS-004 — Scope creep into rate-limit/dry-run
Full TEN-002 §1 #14+. Resolved: Out of scope list.
ISS-005 — Error mapping not closed
HTTP codes for PlanChangeError. Resolved: Proposed Solution error mapping + AC #3–#5.
ISS-006 — Decision reimplementation risk
Handlers might fork logic. Resolved: AC #8 must call decide_plan_change.
§3 — Resolution
All 6 mechanical concerns addressed. Score = 10/10.
End of TASK-TEN-203 audit.