Task — engineering-spec@1

"TEN vertical-pack pricing add-on — per-pack monthly fee (not per-seat) on top of base plan tier; multi-currency; prorate on install/uninstall"

draftTASK-TEN-005
module ten · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-TEN-002, TASK-SKILL-107 · blocks none

§1 — Description (BCP-14 normative)

The TEN service MUST ship vertical-pack pricing add-on at services/ten/src/packs/ with flat per-pack-per-month pricing, multi-currency catalog, per-tenant override, tier-gating, 14-day soft uninstall, install limit, prorated billing via TASK-TEN-003/102, and 5 memory audit kinds.

  1. MUST define closed pack_install_status enum: ('pending','active','uninstalled','suspended_billing_failed','blocked_tier') per DEC-1304. Cardinality asserts 5.
  1. MUST define vertical_pack_installs at migration 0023: (install_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, pack_id TEXT NOT NULL, status pack_install_status NOT NULL DEFAULT 'pending', installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), activated_at TIMESTAMPTZ, soft_uninstalled_at TIMESTAMPTZ, hard_uninstalled_at TIMESTAMPTZ, current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, billing_currency billing_currency_enum NOT NULL, last_charge_amount_minor BIGINT, last_charge_at TIMESTAMPTZ, trace_id CHAR(32)). Partial unique (tenant_id, pack_id) WHERE status NOT IN ('uninstalled','hard_uninstalled').
  1. MUST define vertical_pack_price_catalog at migration 0024: (pack_id TEXT NOT NULL, currency billing_currency_enum NOT NULL, min_tier plan_tier NOT NULL, monthly_price_minor BIGINT NOT NULL CHECK (monthly_price_minor > 0), version INT NOT NULL, effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (pack_id, currency, version)). Versioned for price-change grandfathering.
  1. MUST define vertical_pack_overrides at migration 0025: (id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL, pack_id TEXT NOT NULL, currency billing_currency_enum NOT NULL, monthly_price_minor BIGINT NOT NULL, justification TEXT NOT NULL, set_by_subject_id UUID NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ). Partial unique (tenant_id, pack_id, currency) WHERE expires_at IS NULL OR expires_at > now().
  1. MUST enforce RLS on all 3 tables scoped to tenant_id; vertical_pack_overrides requires cfo role to insert.
  1. MUST expose POST /v1/admin/tenants/{tid}/packs/install body { pack_id }. Handler:
  1. MUST tier-gate per DEC-1306. Lookup pack's min_tier; compare with tenants.plan_tier. If insufficient → 403 + tier_upgrade_required + emit ten.pack_tier_blocked sev-2.
  1. MUST resolve price per price_resolver.rs::resolve(tenant, pack, currency):
  1. MUST expose POST /v1/admin/tenants/{tid}/packs/{install_id}/uninstall per DEC-1305:
  1. MUST expose POST /v1/admin/tenants/{tid}/packs/{install_id}/reinstall within 14d window:
  1. MUST monthly recurring charge at billing-cycle-anchor (consistent with TASK-TEN-003 DEC-788). Per active install, dispatch charge via configured rail. On failure: increment retry; after TASK-TEN-003-style 3 retries → status='suspended_billing_failed' + emit ten.pack_billing_failed sev-1.
  1. MUST support per-tenant override via POST /v1/admin/tenants/{tid}/packs/{pack_id}/override (cfo role) body { currency, monthly_price_minor, justification, expires_at? }. Emit ten.pack_price_overridden sev-1.
  1. MUST enforce 50-pack-install limit per DEC-1307. COUNT(*) WHERE tenant_id=$1 AND status IN ('pending','active') ≥ 50 → 403 + install_limit_exceeded (Enterprise tier can request via support).
  1. MUST grandfather pack pricing per DEC-1309. When catalog version changes:
  1. MUST emit 5 memory audit kinds per DEC-1308:
  1. MUST PII-scrub: justification_sha256 only in chain; raw in DB.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT allow per-seat pack pricing per DEC-1300.
  1. MUST NOT install pack below min_tier per DEC-1306.
  1. MUST NOT charge VND tenant via Stripe rail or vice versa (consistent with TASK-TEN-003 DEC-784 + TASK-TEN-102 DEC-973).

§2 — Why this design (rationale)

Why flat per-pack pricing (§1 #1, DEC-1300)? Consultancies operate 1-to-many: one partner with the Legal Pack covers 50 clients. Per-seat would multiply the cost by 50× → packs become uneconomic for the core target segment.

Why 90-day grandfather (§1 #14, DEC-1309)? Mid-period price hikes feel like bait-and-switch. 90 days = one quarterly review cycle for the tenant to budget the new price.

Why 50-pack limit (§1 #13, DEC-1307)? Skill bundle loading at ~200KB per pack × 50 = 10MB per tenant — tolerable. Beyond 50 = bundle bloat + UX confusion (which pack does what?).

Why 14d soft uninstall (§1 #9, DEC-1305)? Accidental uninstall is real (admin click error; sales demo cleanup). 14d grace = users can reinstall without re-onboarding skill state.


§3 — API contract

-- 0023_vertical_pack_installs.sql
CREATE TYPE pack_install_status AS ENUM ('pending','active','uninstalled','suspended_billing_failed','blocked_tier');

CREATE TABLE vertical_pack_installs (
  install_id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  pack_id TEXT NOT NULL,
  status pack_install_status NOT NULL DEFAULT 'pending',
  installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  activated_at TIMESTAMPTZ,
  soft_uninstalled_at TIMESTAMPTZ,
  hard_uninstalled_at TIMESTAMPTZ,
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  billing_currency billing_currency_enum NOT NULL,
  install_time_price_version INT NOT NULL,
  last_charge_amount_minor BIGINT,
  last_charge_at TIMESTAMPTZ,
  trace_id CHAR(32)
);
CREATE UNIQUE INDEX uniq_active_pack_install
  ON vertical_pack_installs(tenant_id, pack_id)
  WHERE status NOT IN ('uninstalled');
ALTER TABLE vertical_pack_installs ENABLE ROW LEVEL SECURITY;
CREATE POLICY vertical_pack_installs_rls ON vertical_pack_installs
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON vertical_pack_installs FROM cyberos_app;
GRANT UPDATE (status, activated_at, soft_uninstalled_at, hard_uninstalled_at,
              current_period_start, current_period_end, last_charge_amount_minor, last_charge_at)
  ON vertical_pack_installs TO cyberos_app;

-- 0024_vertical_pack_price_catalog.sql
CREATE TABLE vertical_pack_price_catalog (
  pack_id TEXT NOT NULL,
  currency billing_currency_enum NOT NULL,
  min_tier plan_tier NOT NULL,
  monthly_price_minor BIGINT NOT NULL CHECK (monthly_price_minor > 0),
  version INT NOT NULL,
  effective_from TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (pack_id, currency, version)
);
REVOKE UPDATE, DELETE ON vertical_pack_price_catalog FROM cyberos_app;

-- 0025_vertical_pack_overrides.sql
CREATE TABLE vertical_pack_overrides (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  pack_id TEXT NOT NULL,
  currency billing_currency_enum NOT NULL,
  monthly_price_minor BIGINT NOT NULL,
  justification TEXT NOT NULL,
  set_by_subject_id UUID NOT NULL,
  set_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uniq_active_pack_override
  ON vertical_pack_overrides(tenant_id, pack_id, currency)
  WHERE expires_at IS NULL OR expires_at > now();
ALTER TABLE vertical_pack_overrides ENABLE ROW LEVEL SECURITY;
CREATE POLICY vertical_pack_overrides_rls ON vertical_pack_overrides
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON vertical_pack_overrides FROM cyberos_app;

Endpoints:

POST   /v1/admin/tenants/{tid}/packs/install
POST   /v1/admin/tenants/{tid}/packs/{install_id}/uninstall
POST   /v1/admin/tenants/{tid}/packs/{install_id}/reinstall
POST   /v1/admin/tenants/{tid}/packs/{pack_id}/override     (cfo)
GET    /v1/admin/tenants/{tid}/packs
GET    /v1/admin/packs/catalog                              (public-read)

§4 — Acceptance criteria

  1. pack_install_status cardinality 5.
  2. Install creates row + prorated invoice — pack installed mid-period charges prorated amount.
  3. Tier gate — install below min_tier → 403 + tier_upgrade_required + audit.
  4. 50-pack limit — 51st install → 403 + install_limit_exceeded.
  5. Per-tenant override — cfo override → resolver uses override price.
  6. Override expires — past expires_at → resolver falls back to catalog.
  7. Soft uninstall 14d — uninstalled pack still in skill registry until T+14d.
  8. Hard uninstall T+14d — job removes pack from registry; skill bundle deactivated.
  9. Reinstall within 14d — status returns 'active' without re-billing.
  10. Billing failure 3 retries — third failure → status='suspended_billing_failed' + sev-1 audit.
  11. Grandfathered pricing — pack price changed; existing installs charged old price for 90d.
  12. New install uses new price — after catalog version bump, new install charged new price immediately.
  13. VND tenant uses TASK-TEN-102 rail — pack billing routes through VND PSPs.
  14. USD tenant uses Stripe rail.
  15. Cross-rail rejected — VND tenant cannot install pack priced only in USD → 400.
  16. Per-seat attempt rejected — API endpoint has no seat_count field; pricing is flat.
  17. 5 memory audit kinds emitted — full lifecycle.
  18. Cfo-only override — non-cfo override attempt → 403.
  19. RLS isolation — tenant A's installs invisible to tenant B.
  20. PII scrub — justification_sha256 in chain only.

§5 — Verification

#[tokio::test]
async fn install_charges_prorated() {
    let ctx = TestContext::with_stripe_tenant("acme", PlanTier::Team).await;
    ctx.seed_pack_catalog("legal-pack", BillingCurrency::Usd, PlanTier::Team, 9900).await;
    ctx.travel_to_mid_period().await;
    let r = ctx.install_pack(ctx.tenant_id, "legal-pack").await;
    assert_eq!(r.status(), 201);
    let charge: i64 = ctx.last_stripe_invoice_amount().await;
    assert!(charge < 9900 && charge > 4000); // prorated half-month
}

#[tokio::test]
async fn tier_gate_blocks_starter() {
    let ctx = TestContext::with_stripe_tenant("starter-co", PlanTier::Starter).await;
    ctx.seed_pack_catalog("enterprise-pack", BillingCurrency::Usd, PlanTier::Enterprise, 99900).await;
    let r = ctx.install_pack(ctx.tenant_id, "enterprise-pack").await;
    assert_eq!(r.status(), 403);
    let audit = ctx.memory_rows().await;
    assert!(audit.iter().any(|r| r.kind == "ten.pack_tier_blocked"));
}

#[tokio::test]
async fn soft_uninstall_then_reinstall() {
    let ctx = TestContext::new().await;
    let install_id = ctx.install_pack_for_test().await;
    ctx.uninstall_pack(install_id).await;
    assert_eq!(ctx.load_status(install_id).await, "uninstalled");
    let r = ctx.reinstall_pack(install_id).await;
    assert_eq!(r.status(), 200);
    assert_eq!(ctx.load_status(install_id).await, "active");
}

#[tokio::test]
async fn grandfathered_for_90d() {
    let ctx = TestContext::new().await;
    ctx.seed_pack_catalog_v1("legal-pack", BillingCurrency::Usd, PlanTier::Team, 9900).await;
    let install_id = ctx.install_pack_for_test().await;
    ctx.bump_catalog_version("legal-pack", 14900).await;  // price up 50%

    ctx.travel_clock_forward(Duration::from_days(60)).await;
    let charge1 = ctx.run_monthly_billing(install_id).await;
    assert_eq!(charge1, 9900);  // grandfathered

    ctx.travel_clock_forward(Duration::from_days(40)).await;  // now T+100d
    let charge2 = ctx.run_monthly_billing(install_id).await;
    assert_eq!(charge2, 14900);  // new price
}

// 5.5 cfo-only override
// 5.6 50-pack limit
// 5.7 billing failure suspends
// 5.8 cross-rail rejection
// 5.9 cardinality enum
// 5.10 audit emission

§7 — Dependencies

Upstream: TASK-TEN-002 (plan tiers — min_tier semantics), TASK-SKILL-107 (pack registry — pack_id source). Cross-module: TASK-TEN-003 (stripe rail), TASK-TEN-102 (vnd rail), TASK-TEN-004 (metering integration), TASK-INV-001 (invoice line items), TASK-AUTH-101 (cfo role), TASK-AI-003, TASK-MEMORY-111. Downstream: None.


§8 — Example payload

ten.pack_installed:

{
  "kind": "ten.pack_installed",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.tenant_admin.456",
  "trace_id": "...",
  "occurred_at": "2026-05-17T...",
  "payload": {
    "install_id": "0190...",
    "pack_id": "cyberos.packs.legal",
    "billing_currency": "USD",
    "monthly_price_minor": 9900,
    "prorated_charge_minor": 4523,
    "via_reinstall": false,
    "price_version": 3
  }
}

§9 — Open questions

Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Pack not in registryTASK-SKILL-107 lookup404Caller fixes pack_id
Tier insufficientcheck403 + tier_blocked auditCaller upgrades plan
50-pack limit hitcount403 + enterprise_override_requiredEnterprise tenant contacts sales
Duplicate installpartial unique409 + already_installedInherent
Billing failurerail returns errorRetries; status=suspended_billing_failed after 3CFO investigates
Override expires mid-periodresolver fallbackNext billing uses catalog priceInherent
Cross-rail attemptrail guard400 + wrong_billing_railCaller uses correct rail
Pack catalog price missing for currencyresolver miss400 + no_price_for_comboPublisher seeds price
Hard-uninstall job stuckwatchdogsev-2 alertManual cleanup
Override above plan-capvalidation400 + override_invalidCFO uses sane price
Soft-uninstall reinstall after 14dwindow check410 + window_expired; new install requiredFresh install
Catalog version effective_from in futurefilterVersion not applied yetInherent
Grandfather window race (T+89d vs T+90d)clock-basedInherent boundary; alert if billing event near boundaryInherent
Cfo override without justificationvalidation400 + justification_requiredInherent
Pack uninstalled while billing in flighttx isolationLast writer wins; pro-rate credit calculatedInherent
Currency change attempt on existing installbilling_currency immutable per TASK-TEN-003Schema rejectionInherent

§11 — Implementation notes

§11.1 Pack install creates a row in vertical_pack_installs AND notifies TASK-SKILL-107 to activate the bundle.

§11.2 Per-currency price catalog typically has 4-5 entries per pack (USD/EUR/SGD/GBP/VND).

§11.3 Override expires_at supports time-bound discounts ("50% off for 6 months").

§11.4 Billing-cycle anchor for pack matches tenant's main subscription anchor.

§11.5 Hard-uninstall job runs hourly; sweeps soft_uninstalled_at < now() - 14d.

§11.6 Grandfather window per-install: install_time_price_version column locks the version for 90d.

§11.7 Cross-rail check uses TASK-TEN-003 + TASK-TEN-102 guards directly.

§11.8 Per-pack revenue tracked separately for publisher revenue-share (slice 4).

§11.9 Skill registry consumes install status via API; TASK-SKILL-107 derived.

§11.10 Override CHECK constraint: cannot exceed 2× catalog price (defensive — typos shouldn't 10× the bill).


End of TASK-TEN-005 spec.