Plan parameters schema and Trial seed
## Original requirement (epic, verbatim)
Fold Trial mode into Billing plans:
- Trial becomes a regular Plan, that is pre-seeded but may be changed by Operator as they please
- All functions of Trial become configurable parameters of the plan
- Trial node: each plan may have multiple products that are coming with the plan. One of them is a Jaah-provided node - operator can pick what kind of node - same as with Trial now and the same controls (reuse) as with user adding products. They may be arbitrary number of products attached
- When user is signing up he is picking the plan from offered plans that are self-served. Operator can change this plan to any other configured in the system that are active/offered, including those that are not Self-served. E.g. Operator may choose a different flavor of a Trial, such as one without a node
- Operator can override Plan parameters per organization, instance of the plan
- One of the goals is to make it more flexible with less specialized configuration knobs
Settled inputs: BA PRD (Task 5248: R1–R9, D-1…D-9, A-1…A-11, NC-1…NC-9) and Architecture (Task 5249: DA-1…DA-6, §1–§13). Use `task_read` on 5248/5249 for the full documents; this body carries everything this child needs. This child is C1 of 9 (C1…C9) of epic "Fold Trial Mode Into Billing Plans".
Binding operator decisions: D-1 bundled products included in plan price · D-2 leaving a plan → today's trial-node keep-or-stop rule generalized · D-3 paid sign-up plan paid after approval · D-4 old knobs move onto plans and are deleted; empty plan value = unlimited/off, not platform default · D-5 attachable products: Shared node (tier), Dedicated node (size), Jaah AI · D-6 per-org overrides: limits, duration/end date, bundled products, price · D-7 any plan with a duration pauses work like Trial when it ends · D-8 operator-chosen Default plan knob (seeded Trial); any plan retirable except the current default · D-9 Jaah AI bundle credits at plan start + every billing month, may be zero, no rollover · DA-1 paid subscription ends → Default plan · DA-2 plan products edit applies to all members without a products override · DA-3 duration edit is live: end = plan_started_at + duration_days unless overridden · DA-4 plan move clears ALL overrides incl. caps · DA-5 operator-created orgs get bundled products · DA-6 returning to a plan is fresh every time.
Quality bar: every UI string follows docs/standards/ui-copy-standards.md; empty values read "Unlimited"/"Never", never a blank. A literally-compliant but confusing/unreadable result fails this child. Everything is live on dev.jaah.ai after merge — no feature flag (AC9.1, R29).
## This child — scope (HOW, from Architecture Task 5249 §2)
One Alembic revision + SQLAlchemy models; expand-only — nothing is dropped, nothing existing is rewritten except the backfills listed:
- `billing_plans` += `duration_days INTEGER NULL CHECK >0` (NULL = never ends), `hosting_allowed BOOLEAN NOT NULL DEFAULT true`. Existing six `limit_*` unchanged in type; this revision backfills every NULL `limit_*` on existing rows (team, enterprise) with today's knob value (A-3) so C2 can read NULL as unlimited.
- New `billing_plan_products` (operator plane, ADR-0076 entity shape): `id uuid pk`, `plan_id uuid FK billing_plans ON DELETE CASCADE`, `kind text CHECK IN ('shared_node','dedicated_node','jaah_ai_credit')`, `quantity int NOT NULL CHECK >=1`, `tier_key`, `instance_type`, `storage_gb`, `credit_cents bigint CHECK >=0`, `sort_order`, entity fields. Per-kind CHECKs: shared ⇒ tier_key; dedicated ⇒ instance_type, storage_gb; credit ⇒ credit_cents. No FK to `rental_prices` (NC-2).
- `orgs` += `plan_started_at timestamptz NOT NULL DEFAULT now()`, `plan_end_overridden bool NOT NULL DEFAULT false`, `plan_end_override timestamptz NULL`, `hosting_allowed_override bool NULL`, `products_overridden bool NOT NULL DEFAULT false`, `pending_plan_key text NULL`. Existing `orgs.limit_*`: NULL = plan, value = override.
- New `org_plan_products` (OrgScoped, `org_unique`) — org-scoped copy of billing_plan_products keyed by org_id (whole-list products override). `org_signups.plan_key text NULL`.
- New `org_bundled_products` (org-scoped instances; replaces `org_trial_nodes` later): `id uuid`, `org_id`, `fingerprint text`, `ordinal int CHECK >=1`, `kind text`, `node_id uuid NULL` (NO FK), `hosting_id uuid NULL` FK node_rentals SET NULL, `state text CHECK IN ('provisioning','failed','live','released','kept','ended')`, `released_at`, `error`, `attempts int`, entity fields. Unique partial index `(org_id, fingerprint, ordinal) WHERE state NOT IN ('kept','ended')`. Fingerprint grammar pinned: `shared_node:<tier_key>` · `dedicated_node:<instance_type>:<storage_gb>`.
- `node_rentals` CHECKs widened: `kind` + 'bundled', `stop_cause` + 'plan_end'; `NODE_ORDER_PATHS` + 'bundle_keep'; event kinds + `rental.bundle_ending`. NO rewrite of existing node_rentals/org_trial_nodes rows (C4 adopts them in code — Architecture §4.7 — because today's `trial_hostings._adopt` would re-create a trial hosting).
- Seed/backfill (§2.6), knob values read from `system_state` (operator tier) or, when no row exists, the knob's code default hard-coded in the revision:
1. Insert plan `trial`: name "Trial", blurb = today's `TRIAL_BLURB`, self_serve=true, active=true, sort_order = min−1, duration_days = trial_length_days (0⇒NULL), caps = org_limit_* (0⇒NULL), hosting_allowed = NOT rent_requires_paid_plan (operator value). If trial_node_placement='multi_node_host': one product shared_node × 1, tier_key='shared'.
2. Other plan rows: NULL limit_* ← knob value (A-3).
3. `system_state` knob row `default_plan_key='trial'` (D-8); `rental_aws_credential_id` ← `trial_node_aws_credential_id` when the former is empty (AC8.3).
4. Org-tier `rent_requires_paid_plan` org_settings rows ⇒ `orgs.hosting_allowed_override = NOT value`.
5. plan_started_at: created_at for plan_key='trial' orgs, else COALESCE(plan_chosen_at, created_at).
6. Trial-org end with 1-hour tolerance: trial_ends_at NULL while knob>0 ⇒ plan_end_overridden=true, override NULL; |trial_ends_at − (created_at + knob days)| > 1h ⇒ override = trial_ends_at; else no override. Knob=0 and trial_ends_at set ⇒ override = trial_ends_at.
7. Every org with plan_key='trial', lifecycle='active' and no org_trial_nodes row ⇒ products_overridden=true with no lines + one `org_plan_overrides_migrated` activity row each. Pending orgs and Team/Enterprise orgs are NOT overridden.
- Models only: no consumer rewiring here (that is C2+). Revision id follows the repo's sequential-revision rule; migration must be additive (docs/standards/coding-standards.md).
- Migration test on a fixture: trial org with node, trial without node, pending org, no-end org, edited end, team plan with NULL caps, org-tier hosting row — proving AC9.2 equality of the migrated values.
## Acceptance criteria (PRD, verbatim) carried by this child
- AC1.1 (part: the row) A plan row "Trial" (key `trial`) exists in Settings > Billing > Plans on every environment after deploy, listed alongside Team/Enterprise with the same editor and actions (edit name, blurb, sort, self-serve, price, parameters, products, retire). — this child delivers the row; C6 delivers the editor.
- AC8.3 (part: the value) The AWS credential previously on `trial_node_aws_credential_id` still governs where hosted machines launch when no hosting credential is set — moved to its living home (Billing > Hosting), not lost.
- AC9.2 Existing orgs after migration: same plan name, same effective caps, same end date, same trial node running untouched, same dispatch eligibility as before (A-2, A-3). Verify: before/after snapshot on dev of `GET /api/orgs/{id}/limits`, `/plan`, trial-node status for every tenant org — diff empty except vocabulary renames.
- AC9.3 (part: expand) Migrations are additive while two releases overlap (coding standards): the old knob rows are read by nothing new and deleted in the release after, or the deploy is proven single-release safe by the Architect.
## Out of scope:
Do NOT touch any consumer of the plan (org_limits, pool_state, hosting_catalog, org_plan_admin, billing/*, trial_node.py, trial_hostings.py, web) — C2–C8 own those. Do NOT delete any knob, column, table or CHECK value (C9 contracts). Do NOT rewrite existing `node_rentals` or `org_trial_nodes` rows. Only C1, C2 and C9 add Alembic revisions in this epic.
Epic out of scope (PRD §5, verbatim):
- NC-1 Stripe money model: monthly USD, Stripe owns the price; no new intervals/currencies, no proration redesign. `billing/plans.py::push_price` mechanics unchanged except generalizing the per-org custom price beyond Enterprise.
- NC-2 The hosting price catalogue (`rental_prices`), tiers/sizes and their editor — read by bundles, not changed.
- NC-3 Placement/isolation of Shared nodes (`pick_shared_host`, ADR-0130) — reused as is.
- NC-4 Abuse quotas, cron minimum period, sign-up knobs, `rental_grace_days`/`rental_retention_days`, hosting offering switches (A-9) stay global knobs.
- NC-5 Jaah AI wallet/top-up/refund mechanics (`billing/jaah_credit.py`, `token_billing`) — bundles only add a monthly credit entry; no change to top-ups, auto-recharge or refunds.
- NC-6 No change to the approval policy itself (manual review, auto-approve domains and their daily cap).
- NC-7 No new product types beyond D-5 (no storage-only, GitHub runner, or self-hosted products as bundles).
- NC-8 Operator orgs (org zero / own machines) keep their exemptions; bundles never provision into them.
- NC-9 No forge/Slack/notification-channel redesign; reuse existing billing notices for warnings.
Deferred (do NOT build): abuse quotas as plan parameters; Jaah AI credit rollover; annual plans; a configurable bundle provision throttle; tenant self-service switching between free plans.
Done means:
- Migration fixture test passes: each fixture org's migrated plan_started_at / end override / hosting_allowed_override / products_overridden matches the §2.6 rules; team/enterprise have no NULL `limit_*` that previously resolved to a knob; trial plan row values equal the knob values (oracle: the new pytest, cited output).
- On dev after deploy: `SELECT key,name,duration_days,limit_runner_slots,limit_nodes,limit_projects,limit_users,limit_active_worktrees,limit_github_runners,hosting_allowed FROM billing_plans` shows the Trial row with today's knob values (14 days, 4/2/10/10/4/2 unless edited) — cite the query output.
- `alembic upgrade head` then `downgrade -1` then `upgrade head` on the local stack succeeds.
- BEFORE merging: capture an AC9.2 baseline on dev of `GET /api/orgs/{id}/limits`, `GET /api/orgs/{id}/plan` and trial-node status for EVERY tenant org, and upload it as a `file` task artifact so C4 and C9 can diff against it. After deploy: re-snapshot and show the diff is empty except new fields.
- `./dc check full` green; PR merged into develop; the change is live on dev.jaah.ai with no flag (AC9.1).
- Rendered report cites file:line per AC above and the observed output of each oracle.
Outcome
Revision 0374 merged (#4618): plan params, product/bundle tables, Trial seeded; full gate green. Dev at 0374, AC9.2 before/after diff empty (both uploaded). Code Trial ignores the row until C2.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Awaiting response
- Awaiting response
- Runner resumed
- Awaiting response
- Awaiting response
- Runner resumed
- Awaiting response
- Awaiting response
- Runner resumed
- Runner ended (done)