From f00d5d209f743c40bce9afce125326bc663a76eb Mon Sep 17 00:00:00 2001 From: Diffora Date: Thu, 16 Jul 2026 17:23:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(bss):=20monetization=20design=20program=20?= =?UTF-8?q?=E2=80=94=20pricing,=20rating,=20subscriptions,=20products,=20U?= =?UTF-8?q?C=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full BSS design program squashed to one commit against main (44 commits, 2026-06-28…2026-07-16). Docs only; gates green throughout (cfs validate / validate-toc / check-language). PRICING (Plan & Price Modeling — gears/bss/pricing): - PRD + complete 12-slice design set: Catalog Foundation (publish-through- the-engine, fail-closed validation, frozen read model + pricingSnapshotRef), plan definition, price structure & model kinds, currency/tax, governance (two-person materiality), consumer contracts, PriceWindow machinery consolidated into the gear (ADR-0003), bundles / price lists / advanced primitives, AuthZ (PEP catalog), lifecycle + operator efficiency; pricing_ table prefix; module renamed plan-price → pricing. - Decision register D-01…D-46 + R-01…R-14 closed; slice-walkthrough gap waves H1–H31; 8-axis canonical scope key; server-side price resolution. RATING (ex-Tariffs — gears/bss/rating): - Tariffs PRD vendored as baseline → cross-gear seam map vs pricing (SEAMS A–H: scope key, overlays, windows, snapshot, models/metering, proration/FX, governance/bundles) → resolutions propagated into the PRD. - Design slices 01–11 (pure-function evaluation core: foundation, selection/eligibility, metering models, overlays, commitments, coupons, FX, retroactivity, period/plan-change, governance/ASC606, consumer contracts). - ADR-0002: Tariffs + rating pipeline consolidated into ONE rating gear (rating-core no-I/O crate + pipeline); migration commits A–C; pipeline slices 12–16 authored (usage ingestion & normalization, windowed Q store & attribution, unit synthesis & period tick, rated output & delta dedup & balance effects, Billing handoff & operations). - Decision register T-D-01…T-D-17; SEAMS §I registry seams (RG1 open, RG2/RG3 closed) and §J Usage Collector ingestion bridge (UC1–UC6: transport gates slice-12, watermark, attribution join, dedup-key derivation, correction visibility, temporal shape). - usage_record hot/cold tiering posture (S3-compatible Parquet + digest- verified manifest to the ≥7y correction horizon). SUBSCRIPTIONS (gears/bss/subscriptions): - Single normative PRD (vendored VHP-806, merged with the predecessor entitlements PRD) + SEAMS + DESIGN set; SUB-D-01…12 (scheduled intents, updateQuantity, collectionPaused, ramps, activation date trio, transfer, void, grace polish); design-review veto batch + consistency passes. PRODUCTS registry (gears/bss/products — NEW): - Registry PRD vendored from constructorfabric PR #4177 @ 6d3aab4 (upstream-detach: this repo is canonical; provenance note kept); RG2 fix applied in text (unit ≠ dimension — declared dimension set is a plan-price concern; separate SKUs demoted rule → option; composite = output unit, inputs are units); gear renamed product-sku → products (dir + 135-id prefix); RG3 naming reconciled; registry pointers de-staled across subscriptions/pricing. USAGE-COLLECTOR (gears/system/usage-collector — proposal doc, NEW): - Phase-2 'Usage Event Feed & Ingestion Watermarks' PRD: durable per-tenant acceptedSeq feed (usage + compensations + status transitions), deterministic cursor replay, acceptance-progress watermarks, feed-lag NFR + ingestion non-regression, Plugin SPI accepted-order scan. Executes the v1 DESIGN §4 reserved hooks, additive under ADR-0006; driving consumer = rating pipeline slice 12. CROSS-CUTTING product decisions (2026-07-16, confirmed): - D-44 / T-D-17 — level-based billing into launch: aggregationFunction {sum, peak, time_weighted} + aggregationGranularity; granule fold over gauge samples, window Q = Σ folds (additive — every counter invariant untouched); cloudlet peak-per-hour, storage GB-month. - D-45 — first-class includedAllowance {quantity, rolloverPolicy}, publish-compiled (none → $0 first band + frozen marker; carry → D-43 per-period promotional grant, Billing executes). - D-46 — registry sellable flag + sellability-gate predicate 6 (standalone lines; bundle components exempt). - gears-rust declared the canonical home for BSS gear docs; vhp-architecture is legacy provenance (no drift-tracking obligation). --- ...-cf-bss-pricing-adr-canonical-scope-key.md | 152 + ...-pricing-adr-grandfathering-cohort-axis.md | 122 + ...s-pricing-adr-pricewindow-consolidation.md | 140 + gears/bss/pricing/docs/DECISIONS.md | 468 +++ gears/bss/pricing/docs/DESIGN.md | 510 ++++ gears/bss/pricing/docs/PRD.md | 2623 +++++++++++++++++ gears/bss/pricing/docs/STRIPE-GAP-ANALYSIS.md | 261 ++ .../bss/pricing/docs/design/01-foundation.md | 410 +++ .../pricing/docs/design/02-plan-definition.md | 432 +++ .../pricing/docs/design/03-price-structure.md | 393 +++ .../pricing/docs/design/04-currency-tax.md | 373 +++ .../bss/pricing/docs/design/05-governance.md | 513 ++++ .../docs/design/06-consumer-contracts.md | 399 +++ .../docs/design/07-pricewindow-linkage.md | 470 +++ gears/bss/pricing/docs/design/08-bundles.md | 259 ++ .../pricing/docs/design/09-price-overlays.md | 361 +++ .../docs/design/10-advanced-primitives.md | 402 +++ gears/bss/pricing/docs/design/11-lifecycle.md | 385 +++ .../docs/design/12-operator-efficiency.md | 381 +++ gears/bss/pricing/docs/design/README.md | 71 + gears/bss/products/docs/PRD.md | 1663 +++++++++++ ...pt-cf-bss-rating-adr-scope-key-adoption.md | 101 + ...ss-rating-adr-rating-gear-consolidation.md | 232 ++ gears/bss/rating/docs/DECISIONS.md | 58 + gears/bss/rating/docs/DESIGN.md | 240 ++ gears/bss/rating/docs/PRD.md | 1387 +++++++++ gears/bss/rating/docs/SEAMS.md | 240 ++ gears/bss/rating/docs/design/01-foundation.md | 339 +++ .../docs/design/02-selection-eligibility.md | 315 ++ .../rating/docs/design/03-metering-models.md | 349 +++ .../docs/design/04-overlays-precedence.md | 313 ++ .../design/05-commitments-reservations.md | 324 ++ gears/bss/rating/docs/design/06-coupons.md | 318 ++ .../bss/rating/docs/design/07-currency-fx.md | 301 ++ .../design/08-retroactivity-corrections.md | 306 ++ .../docs/design/09-period-plan-change.md | 308 ++ .../docs/design/10-governance-asc606.md | 337 +++ .../docs/design/11-consumer-contracts.md | 440 +++ .../12-usage-ingestion-normalization.md | 328 +++ .../docs/design/13-q-store-attribution.md | 291 ++ .../design/14-unit-synthesis-period-tick.md | 300 ++ .../design/15-rated-output-balance-effects.md | 302 ++ .../design/16-billing-handoff-operations.md | 279 ++ gears/bss/rating/docs/design/README.md | 76 + ...ions-adr-manifest-closed-status-machine.md | 109 + ...s-subscriptions-adr-when-not-math-split.md | 109 + ...ions-adr-scheduled-intents-on-aggregate.md | 108 + gears/bss/subscriptions/docs/DECISIONS.md | 111 + gears/bss/subscriptions/docs/DESIGN.md | 466 +++ gears/bss/subscriptions/docs/PRD.md | 1445 +++++++++ gears/bss/subscriptions/docs/SEAMS.md | 210 ++ .../docs/STRIPE-ZUORA-GAP-ANALYSIS.md | 340 +++ .../docs/design/01-foundation-lifecycle.md | 333 +++ .../docs/design/02-composition-versioning.md | 231 ++ .../docs/design/03-plan-changes.md | 278 ++ .../design/04-suspension-renewal-grace.md | 258 ++ .../docs/design/05-entitlements.md | 240 ++ .../subscriptions/docs/design/06-trials.md | 243 ++ .../docs/design/07-tenancy-transfer.md | 210 ++ .../docs/design/08-events-billing.md | 250 ++ .../docs/design/09-consumer-contracts.md | 248 ++ gears/bss/subscriptions/docs/design/README.md | 58 + .../docs/PRD-phase2-usage-event-feed.md | 455 +++ 63 files changed, 23974 insertions(+) create mode 100644 gears/bss/pricing/docs/ADR/0001-cpt-cf-bss-pricing-adr-canonical-scope-key.md create mode 100644 gears/bss/pricing/docs/ADR/0002-cpt-cf-bss-pricing-adr-grandfathering-cohort-axis.md create mode 100644 gears/bss/pricing/docs/ADR/0003-cpt-cf-bss-pricing-adr-pricewindow-consolidation.md create mode 100644 gears/bss/pricing/docs/DECISIONS.md create mode 100644 gears/bss/pricing/docs/DESIGN.md create mode 100644 gears/bss/pricing/docs/PRD.md create mode 100644 gears/bss/pricing/docs/STRIPE-GAP-ANALYSIS.md create mode 100644 gears/bss/pricing/docs/design/01-foundation.md create mode 100644 gears/bss/pricing/docs/design/02-plan-definition.md create mode 100644 gears/bss/pricing/docs/design/03-price-structure.md create mode 100644 gears/bss/pricing/docs/design/04-currency-tax.md create mode 100644 gears/bss/pricing/docs/design/05-governance.md create mode 100644 gears/bss/pricing/docs/design/06-consumer-contracts.md create mode 100644 gears/bss/pricing/docs/design/07-pricewindow-linkage.md create mode 100644 gears/bss/pricing/docs/design/08-bundles.md create mode 100644 gears/bss/pricing/docs/design/09-price-overlays.md create mode 100644 gears/bss/pricing/docs/design/10-advanced-primitives.md create mode 100644 gears/bss/pricing/docs/design/11-lifecycle.md create mode 100644 gears/bss/pricing/docs/design/12-operator-efficiency.md create mode 100644 gears/bss/pricing/docs/design/README.md create mode 100644 gears/bss/products/docs/PRD.md create mode 100644 gears/bss/rating/docs/ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md create mode 100644 gears/bss/rating/docs/ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md create mode 100644 gears/bss/rating/docs/DECISIONS.md create mode 100644 gears/bss/rating/docs/DESIGN.md create mode 100644 gears/bss/rating/docs/PRD.md create mode 100644 gears/bss/rating/docs/SEAMS.md create mode 100644 gears/bss/rating/docs/design/01-foundation.md create mode 100644 gears/bss/rating/docs/design/02-selection-eligibility.md create mode 100644 gears/bss/rating/docs/design/03-metering-models.md create mode 100644 gears/bss/rating/docs/design/04-overlays-precedence.md create mode 100644 gears/bss/rating/docs/design/05-commitments-reservations.md create mode 100644 gears/bss/rating/docs/design/06-coupons.md create mode 100644 gears/bss/rating/docs/design/07-currency-fx.md create mode 100644 gears/bss/rating/docs/design/08-retroactivity-corrections.md create mode 100644 gears/bss/rating/docs/design/09-period-plan-change.md create mode 100644 gears/bss/rating/docs/design/10-governance-asc606.md create mode 100644 gears/bss/rating/docs/design/11-consumer-contracts.md create mode 100644 gears/bss/rating/docs/design/12-usage-ingestion-normalization.md create mode 100644 gears/bss/rating/docs/design/13-q-store-attribution.md create mode 100644 gears/bss/rating/docs/design/14-unit-synthesis-period-tick.md create mode 100644 gears/bss/rating/docs/design/15-rated-output-balance-effects.md create mode 100644 gears/bss/rating/docs/design/16-billing-handoff-operations.md create mode 100644 gears/bss/rating/docs/design/README.md create mode 100644 gears/bss/subscriptions/docs/ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md create mode 100644 gears/bss/subscriptions/docs/ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md create mode 100644 gears/bss/subscriptions/docs/ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md create mode 100644 gears/bss/subscriptions/docs/DECISIONS.md create mode 100644 gears/bss/subscriptions/docs/DESIGN.md create mode 100644 gears/bss/subscriptions/docs/PRD.md create mode 100644 gears/bss/subscriptions/docs/SEAMS.md create mode 100644 gears/bss/subscriptions/docs/STRIPE-ZUORA-GAP-ANALYSIS.md create mode 100644 gears/bss/subscriptions/docs/design/01-foundation-lifecycle.md create mode 100644 gears/bss/subscriptions/docs/design/02-composition-versioning.md create mode 100644 gears/bss/subscriptions/docs/design/03-plan-changes.md create mode 100644 gears/bss/subscriptions/docs/design/04-suspension-renewal-grace.md create mode 100644 gears/bss/subscriptions/docs/design/05-entitlements.md create mode 100644 gears/bss/subscriptions/docs/design/06-trials.md create mode 100644 gears/bss/subscriptions/docs/design/07-tenancy-transfer.md create mode 100644 gears/bss/subscriptions/docs/design/08-events-billing.md create mode 100644 gears/bss/subscriptions/docs/design/09-consumer-contracts.md create mode 100644 gears/bss/subscriptions/docs/design/README.md create mode 100644 gears/system/usage-collector/docs/PRD-phase2-usage-event-feed.md diff --git a/gears/bss/pricing/docs/ADR/0001-cpt-cf-bss-pricing-adr-canonical-scope-key.md b/gears/bss/pricing/docs/ADR/0001-cpt-cf-bss-pricing-adr-canonical-scope-key.md new file mode 100644 index 000000000..b909018f6 --- /dev/null +++ b/gears/bss/pricing/docs/ADR/0001-cpt-cf-bss-pricing-adr-canonical-scope-key.md @@ -0,0 +1,152 @@ +--- +status: accepted +date: 2026-07-09 +decision-makers: "BSS Product Catalog team" +--- + +# ADR-0001: Canonical Price-Row Scope Key — Extend the Manifest Key Additively + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Keep the manifest key unchanged](#keep-the-manifest-key-unchanged) + - [Model components/eligibility as separate plans or rows outside the key](#model-componentseligibility-as-separate-plans-or-rows-outside-the-key) + - [Extend the manifest key additively (chosen)](#extend-the-manifest-key-additively-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-pricing-adr-canonical-scope-key` + +> **Amended by ADR-0002 (2026-07-10)**: the key gains an eighth additive axis — `cohort`, the +> grandfathering generation discriminator (`cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`). +> The additive-extension decision below is unchanged; seven-column statements read as +> eight-column after the amendment. + +## Context and Problem Statement + +Several catalog invariants hang off "the price row's identity": row-uniqueness (no duplicate +rows), supersession (replace the active row within an identity), `PriceWindow` non-overlap (no +two active windows for one identity), and publish-time window coverage. The BSS Architecture +Manifest §4.1/§8.2 and the effective-dating price-windows use case define this identity as +`(plan, currency, region, priceOverlay)`. + +That key is too narrow for the commercial shapes this PRD MUST support on a single `planId`: + +- A **hybrid plan** carries a `recurring` base row **and** a `usage` row (optionally a `one_time_setup` row) at the same time — under the manifest key these collide as "duplicates" of one identity. +- A **one-time plan's** base row and a recurring/hybrid plan's setup row are different charge components that must not collide. +- **Grandfathering** must keep a legacy price live for pre-cutover subscribers while a new price serves new subscribers — two prices that are active **concurrently** for what the manifest key would call one identity, which reads as a non-overlap violation. +- **Plan phases** (trial/intro/evergreen) each carry their own price schedule for one plan. + +What is the canonical identity of a price row, such that all four invariants hold without +banning legitimate multi-component / grandfathered / phased plans? + +## Decision Drivers + +* The four invariants (uniqueness, supersession, non-overlap, coverage) must key off **one** identity, used identically by the catalog and by Tariffs' non-overlap check. +* A hybrid plan's components, a one-time base vs a setup charge, a grandfathered row vs its successor, and per-phase schedules must be **distinct** identities, not duplicates or overlaps. +* The key must not contradict the manifest §4.1/§8.2 non-overlap invariant — it must be a compatible extension, not a fork. +* `brand` must stay **out** of the price-row identity (the manifest `Price` model has no `brand` field; brand pricing is a brand-scoped `PriceOverlay`). +* The identity must reconcile "grandfathered rows are live-resolved" with "published rows are immutable and consumers read frozen snapshots". + +## Considered Options + +* Keep the manifest key `(plan, currency, region, priceOverlay)` unchanged and disallow the colliding shapes. +* Model components / eligibility / phases as separate plans or as rows disambiguated *outside* the key (e.g. a row "type" column not part of uniqueness). +* Extend the manifest key **additively** with `phase`, `priceEligibility`, `chargeKind` (chosen). + +## Decision Outcome + +Chosen option: **extend the manifest key additively**. The single canonical scope key for +row-uniqueness, supersession, `PriceWindow` non-overlap, and window coverage is: + +```text +(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind) -- + cohort (ADR-0002) +``` + +with axis defaults `priceOverlay = base`, `phase =` the plan's terminal `phase_id` (implicit terminal phase auto-created for non-phased plans; id-typed axis — D-19), +`priceEligibility = all_subscriptions`, and `chargeKind` per row +(`recurring | usage | one_time | one_time_setup`). The manifest §4.1/§8.2 non-overlap +invariant and the Tariffs non-overlap key are aligned to this key; the effective-dating use +case's narrower `(plan, currency, region, priceOverlay)` key is **superseded** by this canonical +key for normative purposes. `brand` is **not** an axis — brand-differentiated pricing is a +brand-scoped `PriceOverlay`. + +Chosen because it makes every legitimate shape a **distinct key** while preserving the +non-overlap invariant exactly: a hybrid plan's `recurring`/`usage`/`one_time_setup` rows differ +in `chargeKind`; a grandfathered row and its successor differ in `priceEligibility` and so hold +concurrent active windows **without** violating non-overlap; per-phase schedules differ in +`phase`. It is additive, so it does not fork the manifest — the pre-existing axes keep their +meaning and the new axes carry safe defaults for plans that do not use them. + +### Consequences + +* Row-uniqueness enforcement (a partial `UNIQUE` index over **current** rows — published and not superseded) is on the **seven-column** key (eight with `cohort`, ADR-0002), holding at most one current row per key; **temporal `PriceWindow` non-overlap and coverage are enforced by publish-time validation (Slice 7) + the effective-dating UC (the UC enforcement partner was later absorbed into Slice 7 by ADR-0003), not by the index** (a published predecessor and its scheduled successor legally coexist). The Foundation's `ScopeKey` component constructs and defaults the key centrally (Foundation §4.1). +* Grandfathering reconciles with the frozen-snapshot doctrine: an `existing_grandfathered` row is a **distinct, immutable** key that Tariffs live-resolves; the cutover shortens the current `all_subscriptions` window and schedules the grandfathered copy + successor as one atomic unit, so no coverage gap opens (Foundation §4.3). +* Supersession is explicitly **scoped to one canonical key** and operates within one `priceEligibility` class and one `chargeKind`; it opens/closes a `PriceWindow` rather than overlapping it. +* Tariffs MUST adopt the identical key for its non-overlap check; divergence would re-introduce the collisions this decision removes. Cross-team alignment with Tariffs is required. +* Publish-time coverage resolves on the **base** `priceOverlay`; partner/orgTier/brand overlays are separate `PriceOverlay` rows applied at evaluation, not part of the base coverage check. +* A wider uniqueness index is a modest storage/write cost versus the narrower manifest key — accepted. + +### Confirmation + +* Design review: the Foundation `ScopeKey` component, the `pricing_price` partial `UNIQUE` index (current rows), and the Slice 7 window non-overlap/coverage validation all key off the same seven columns (eight with `cohort`, ADR-0002); no invariant keys off a narrower subset. +* Integration test: a hybrid plan (`recurring` + `usage` + `one_time_setup`) publishes without a duplicate-scope failure; a grandfathered row and its successor hold concurrent active windows without a non-overlap violation; a per-phase schedule publishes per phase. +* Cross-team checkpoint: Tariffs confirms it evaluates the non-overlap check on the identical seven-column key (eight with `cohort`, ADR-0002). + +## Pros and Cons of the Options + +### Keep the manifest key unchanged + +Retain `(plan, currency, region, priceOverlay)` and forbid the colliding shapes. + +* Good, because zero change to the manifest key and the effective-dating use case. +* Bad, because it bans hybrid plans, one-time-setup-on-recurring, per-phase schedules, and time-bound grandfathering on one `planId` — all in-scope PRD requirements. +* Bad, because operators would clone SKUs to work around it, defeating the lifecycle-safety goal. + +### Model components/eligibility as separate plans or rows outside the key + +Disambiguate with a non-key "type" column, or split components/eligibility across multiple plans. + +* Good, because the manifest key text is untouched. +* Bad, because uniqueness/non-overlap then key off an identity that does not match the real row identity — collisions still occur or must be special-cased per shape. +* Bad, because separate plans fragment one commercial offer across many `planId`s, breaking hybrid co-resolution and the plan-change contract. +* Bad, because a non-key type column is exactly a key axis in disguise, but without the enforcement — the worst of both. + +### Extend the manifest key additively (chosen) + +Add `phase`, `priceEligibility`, `chargeKind` with safe defaults. + +* Good, because every legitimate shape is a distinct key and the non-overlap invariant holds by construction. +* Good, because it is additive with defaults — plans that do not use the new axes behave exactly as under the manifest key. +* Good, because it reconciles live-resolved grandfathering with immutable published rows (distinct `priceEligibility` key + immutable row). +* Neutral, because it requires Tariffs to adopt the identical key (a coordinated but one-time alignment). +* Bad, because the uniqueness index widens and the effective-dating use case's narrower key is superseded (a documented normative change, not a silent one). + +## More Information + +Normative catalog-wide statement: [`design/01-foundation.md` §4.1](../design/01-foundation.md#41-canonical-scope-key-normative). +PRD source: [`PRD.md`](../PRD.md) §2.2 (Canonical Scope Key) and §17.5 (Price-Change Mechanisms). +The PRD explicitly names this ADR as a planned child artifact ([`PRD.md`](../PRD.md) §17.8 closing note). + +## Traceability + +- **PRD**: [`PRD.md`](../PRD.md) +- **DESIGN**: [`design/01-foundation.md`](../design/01-foundation.md) + +This decision directly addresses the following requirements or design elements: + +* `cpt-cf-bss-pricing-fr-supersession` — supersession is scoped to one canonical key, opening/closing a `PriceWindow` within one `priceEligibility`/`chargeKind` +* `cpt-cf-bss-pricing-fr-published-rows-append-only` / `cpt-cf-bss-pricing-fr-plan-versioning` — a change writes a new immutable row on a distinct key rather than mutating in place +* `cpt-cf-bss-pricing-fr-hybrid-completeness` — a hybrid plan's `recurring` and `usage` components are distinct keys on one `planId` +* `cpt-cf-bss-pricing-fr-one-time-setup` — a `one_time_setup` charge is a distinct key, not a duplicate of the base row +* `cpt-cf-bss-pricing-fr-plan-phases` — per-phase price schedules are distinct keys via the `phase` axis +* `cpt-cf-bss-pricing-fr-region-brand-taxonomy` — `brand` is not a price-row axis; brand pricing is a brand-scoped `PriceOverlay` +* `cpt-cf-bss-pricing-constraint-canonical-scope-key` — the DESIGN constraint this ADR grounds diff --git a/gears/bss/pricing/docs/ADR/0002-cpt-cf-bss-pricing-adr-grandfathering-cohort-axis.md b/gears/bss/pricing/docs/ADR/0002-cpt-cf-bss-pricing-adr-grandfathering-cohort-axis.md new file mode 100644 index 000000000..ff868b376 --- /dev/null +++ b/gears/bss/pricing/docs/ADR/0002-cpt-cf-bss-pricing-adr-grandfathering-cohort-axis.md @@ -0,0 +1,122 @@ +--- +status: accepted +date: 2026-07-10 +decision-makers: "BSS Product Catalog team" +--- + +# ADR-0002: Multi-Generation Grandfathering — the `cohort` Scope-Key Axis + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Normative one-generation-per-key limit](#normative-one-generation-per-key-limit) + - [Full per-subscription price binding (Stripe model)](#full-per-subscription-price-binding-stripe-model) + - [Additive `cohort` axis + snapshot-derived selection (chosen)](#additive-cohort-axis--snapshot-derived-selection-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` + +## Context and Problem Statement + +Under ADR-0001 a grandfathering cutover schedules the legacy-price copy on the single +`priceEligibility = existing_grandfathered` value of the scope key. The first cutover +therefore **permanently occupies** the grandfathered key: a second cutover on the same +remaining axes (the routine annual reprice where each cohort keeps its signed price) must +schedule a second copy on the identical key — a non-overlap violation — and every escape +hatch is forbidden (a grandfathered row MUST NOT be superseded; its window MUST NOT be +shortened below `grandfatherUntil`). The design silently supported exactly **one grandfathered +generation per key** while the PRD never stated that limit (review finding D-02). + +Multi-cohort retention is table stakes in comparable systems: Stripe/Chargebee/Recurly get N +generations implicitly via per-subscription price binding; Kill Bill gets them catalog-side +via versioned catalogs + `effectiveDateForExistingSubscriptions`. Product direction confirmed +multi-generation grandfathering as a required capability **now**, not a future option. + +## Decision Drivers + +* The routine commercial flow — repeated repricing with each cohort keeping its price — must be expressible on one `planId` without plan cloning or cohort migration. +* Renewal semantics stay **default-move** (`fr-supersession`: a supersession moves the base at next renewal; grandfathering remains the explicit exception) — a wholesale switch to per-subscription binding would invert the default and make the common case (reprice moves everyone) expensive. +* The catalog stays the deterministic source for "what does cohort X pay and why" (append-only rows, windows, audit, mass-repricing exclusions must see every generation as a first-class row). +* All ADR-0001 invariants (uniqueness, supersession scoping, window non-overlap, coverage) must keep holding per identity, with Tariffs evaluating the identical key. +* The extension must be additive — safe defaults for every existing row shape, no manifest fork (same discipline as ADR-0001). + +## Considered Options + +* Keep one generation per key, make the limit normative (reject the second cutover with an error; operator path = tighten `grandfatherUntil`, then cut over). +* Full per-subscription price binding (Stripe model): renewal resolves the subscription's pinned price id; eligibility classes dissolve. +* Extend the canonical scope key **additively** with a `cohort` axis; select among generations by the subscription's pinned snapshot (chosen). + +## Decision Outcome + +Chosen option: **additive `cohort` axis + snapshot-derived selection**. The canonical scope +key becomes: + +```text +(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort) +``` + +* `cohort` is the **grandfathering generation discriminator**: the UTC cutover instant that created the generation. Default `cohort = none` on every non-grandfathered row; publish validation enforces `cohort ≠ none ⇔ priceEligibility = existing_grandfathered`. It is unrelated to `customerGroup` segment pricing. +* **Every cutover creates a new generation**: the copy lands on `(… , existing_grandfathered, chargeKind, cohort = T)`; prior generations' rows and windows are untouched (still immutable, still live-resolvable). The successor stays `(… , all_subscriptions, chargeKind, none)`. A cutover whose instant equals an existing generation's `cohort` is a duplicate key — rejected at compose (`DUPLICATE_SCOPE_KEY`). +* **Selection among generations is by subscription binding, not by class specificity**: most-specific-wins (W3) orders eligibility *classes* only; within `existing_grandfathered`, Tariffs resolves the row whose `cohort` equals the cohort of the subscription's **pinned price id** (`pricingSnapshotRef` already pins resolved price ids — no new binding store). New subscriptions never bind grandfathered rows (unchanged). At a generation's `grandfatherUntil` expiry, that cohort's subscriptions re-bind at next renewal to the current eligible row (unchanged mechanics, now per generation). +* Uniqueness, supersession scoping, window non-overlap, and coverage all key off the **eight-column** key; each generation carries its own window and its own `grandfatherUntil`. + +### Consequences + +* The D-02 impossibility dissolves: sequential cutovers produce coexisting generations (`$100` cohort, `$120` cohort, current `$140`), each a distinct key with concurrent active windows and no non-overlap violation. +* Tariffs MUST adopt the eight-column key for its non-overlap check and the **binding-based within-class selection** (class matching first, then `cohort` from the pinned price id) — cross-team contract update (PRD §9.2). +* Subscriptions is unchanged except that re-bind at `grandfatherUntil` expiry is per generation (its binding/dedup mechanics already operate per subscription). +* The partial `UNIQUE` index (current rows), the Foundation `ScopeKey` component, supersession ("within one `priceEligibility` class, one `cohort`, one `chargeKind`"), Slice 7 coverage/gap checks, mass-repricing exclusions, and clone's "grandfathered rows are not cloned" all extend mechanically to the new axis. +* Unbounded generations are a plan-size concern: the generation count per key participates in the plan/tier size caps (provisional NFR, PRD §14) — a soft cap with a publish warning, not a hard product limit. +* A modest widening of the uniqueness index and every key-carrying surface (read model, events' aggregate identity untouched — `cohort` is a row attribute inside the plan aggregate) — accepted. + +### Confirmation + +* Design review: `ScopeKey`, the partial `UNIQUE` index, Slice 7 validation, and the Tariffs non-overlap check all key off the same **eight** columns; `cohort ≠ none ⇔ existing_grandfathered` is publish-enforced. +* Integration test: two sequential cutovers on one remaining-axes identity yield three concurrently-active rows (two generations + successor); each cohort's subscription resolves its own generation's price; expiry of generation 1's `grandfatherUntil` re-binds only that cohort. +* Cross-team checkpoint: Tariffs confirms class-then-cohort resolution and the eight-column non-overlap key; Subscriptions confirms per-generation re-bind. + +## Pros and Cons of the Options + +### Normative one-generation-per-key limit + +Reject the second cutover (`CUTOVER_GRANDFATHERED_OCCUPIED`) unless the prior generation's `grandfatherUntil` precedes the new instant. + +* Good, because it is honest about the current mechanics at ~zero design cost. +* Bad, because the routine annual-reprice-with-retention scenario — the primary reason grandfathering exists — is impossible on one plan; operators fall back to plan cloning, defeating lifecycle safety. +* Bad, because lifting the limit later means changing the scope key **after** launch — a migration of append-only history, snapshots, and the Tariffs contract; the most expensive possible time to do it. + +### Full per-subscription price binding (Stripe model) + +The subscription's pinned price id is the resolution source at renewal; eligibility classes dissolve. + +* Good, because grandfathering (any depth) is free and D-02/D-03/D-04/D-05 all dissolve. +* Bad, because it inverts renewal semantics to **default-stay**: every subscriber is implicitly grandfathered, and the common case — a supersession moving the whole base at renewal — becomes a mass per-subscription re-bind operation (the documented pain of that model). +* Bad, because pricing truth relocates into subscription state: the catalog alone can no longer answer "who pays what at `t`" (audit, price history, repricing surfaces fragment), and the ratified Tariffs step-2 contract plus the window/coverage safety net would be reworked wholesale. + +### Additive `cohort` axis + snapshot-derived selection (chosen) + +* Good, because each generation is a first-class catalog row (windows, audit, history, exclusions all apply) — the Kill Bill-style catalog answer — while cohort membership rides the **already existing** snapshot pin — the Stripe-style pointer, exactly where default-stay is wanted. +* Good, because it is additive with safe defaults (`cohort = none`), repeating the proven ADR-0001 move; nothing changes for non-grandfathered rows. +* Neutral, because Tariffs' within-class selection gains one input (the pinned row's `cohort`) — a bounded contract change while the class model and default-move renewal stay intact. +* Bad, because the key widens to eight columns and every key-enumerating surface must be touched now — accepted as the cost of not migrating the key post-launch. + +## More Information + +Industry survey and the default-move vs default-stay analysis: review decision D-02 +([`../DECISIONS.md`](../DECISIONS.md)). Interacts with D-04 (per-generation window bound at +`grandfatherUntil`) and D-05 (retirement vs pending cutover) — both apply per generation. + +## Traceability + +- **PRD**: §2.2 (canonical scope key), §1.4 Glossary (`cohort`, Grandfathering, `priceEligibility`), §6.8 (`fr-supersession`), §6.5 (`fr-grandfathering-eligibility`), §17.4 (duplicate-scope rule), §17.5 (cutover mechanism), §9.2 (Tariffs contract) +- **Design**: [`../design/01-foundation.md`](../design/01-foundation.md) §4.1 (scope key), §3.7 (`pricing_price` columns); [`../design/07-pricewindow-linkage.md`](../design/07-pricewindow-linkage.md) (cutover, eligibility resolution) +- **Supersedes in part**: ADR-0001's seven-column statements (the additive-extension decision itself is unchanged) diff --git a/gears/bss/pricing/docs/ADR/0003-cpt-cf-bss-pricing-adr-pricewindow-consolidation.md b/gears/bss/pricing/docs/ADR/0003-cpt-cf-bss-pricing-adr-pricewindow-consolidation.md new file mode 100644 index 000000000..1cbc34ae8 --- /dev/null +++ b/gears/bss/pricing/docs/ADR/0003-cpt-cf-bss-pricing-adr-pricewindow-consolidation.md @@ -0,0 +1,140 @@ +--- +status: accepted +date: 2026-07-10 +decision-makers: "BSS Product Catalog team" +--- + +# ADR-0003: PriceWindow Machinery Consolidated into the Pricing Gear + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Atomic batch window operation in the UC contract](#atomic-batch-window-operation-in-the-uc-contract) + - [Saga with compensation over the external UC](#saga-with-compensation-over-the-external-uc) + - [Consolidate window ownership into the pricing gear (chosen)](#consolidate-window-ownership-into-the-pricing-gear-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-pricing-adr-pricewindow-consolidation` + +## Context and Problem Statement + +The design inherited `PriceWindow` scheduling, UTC activation jobs, and `PriceWindow*` event +mechanics as an **external boundary**: the "effective-dating price-windows use case" +(`UC-effective-dating-price-windows-202601121200`, from the January-2026 monolithic +*Catalog Service* PRD). Slice 7 held only linkage refs plus an event-driven **mirror** of +window state, with fail-closed degradation on mirror lag. + +The review (finding D-03) showed this boundary unimplementable and ownerless: + +- The grandfathering cutover requires **atomic multi-window changes** (shorten the + predecessor's `effectiveTo` + schedule the generation copy + schedule the successor as one + unit). The design claimed "one transaction" — impossible across a component boundary with + local ACID; a partial failure (shorten committed, schedules lost) strands every subscriber + of the plan uncovered at the cutover instant. +- The UC's runtime owner never existed: it targets the defunct monolithic "Catalog Service"; + its sibling UCs became the **registry** (product-sku-management) and **this PRD** + (plan-price-modeling); nothing else consumes it (the registry is unmentioned in it). +- Its two normative deltas (4-axis key; gap-fallback-to-base-price) had already been + superseded by this PRD, and the atomicity requirement had already been assigned to it via + the consolidate-UC open question (PRD §15, owner: Architecture). +- The confirmed topology: all pricing slices are **one deployable gear over one PostgreSQL**; + Tariffs is a separate gear. Windows are *price* windows — attached to price rows, validated + at price publish, consumed via this gear's read model. + +Who owns the `PriceWindow` store, state machine, activation job, and event production — and +how does the cutover get its atomicity? + +## Decision Drivers + +* Cutover atomicity is a coverage-safety invariant: no instant may be left uncovered by a partially-applied multi-window unit. +* The failure mode of any cross-component protocol (partial state latent until the cutover instant) is exactly the class the fail-closed doctrine exists to eliminate. +* A separate component for price-window mechanics is artificial distribution: no second consumer exists, and the mirror + lag-alarm machinery existed only to compensate for the boundary. +* Downstream consumers (Rating, Tariffs) depend on the frozen manifest `PriceWindow*` event names — the producer may move, the contract may not. +* Retirement (D-05), multi-key cutovers (D-28), and coverage checks all simplify to single-transaction logic if windows are local. + +## Considered Options + +* Keep the UC external; add an **atomic batch window operation** to its contract. +* Keep the UC external; run the cutover as a **saga** with compensation. +* **Consolidate window ownership into the pricing gear** (chosen). + +## Decision Outcome + +Chosen option: **consolidate**. Slice 7 owns, inside the pricing gear: + +* the **`pricing_price_window`** store (tenant-scoped; half-open `[from, to)` intervals; non-overlap per canonical scope key enforced in every mutation) and the window **state machine** `scheduled → active → expired` / `scheduled → cancelled`, with historical immutability under the same `REVOKE` + column-whitelist trigger discipline as `pricing_price`; +* the **scheduling/cancellation/adjustment API** (`plan × write`; every mutation runs coverage/gap validation in-transaction — no side door exists); +* the **UTC activation/expiration job** as a coordination-lease singleton (idempotent; events ordered per `(tenant, plan)`); +* **`PriceWindow*` event production** from the gear outbox — the frozen manifest names (`PriceWindowScheduled`/`Activated`/`Expired`/`Cancelled`) are preserved; only the producer changes. + +The cutover's multi-window unit (and retirement's unwind of a live cutover, D-05) is thereby +a **local ACID transaction**. The mirror (`pricing_price_window_link`), the `mirror_lag` and +`coverage_gap` alarms, and review item D-26 (routing of "UC-side" mutations) are **deleted / +dissolved** — no external mutation path exists. The legacy UC document is retained as +**scenario source material only** (banner-marked), with explicit dispositions: FX rate-lock +**rejected** (the catalog performs no FX — Tariffs/PLAL), subscription/revenue impact preview +**out of catalog scope** (needs Subscriptions data), the `suspended` window state **not +adopted**. The PRD §15 consolidation question is **answered** (formal Architecture ack +pending). + +### Consequences + +* Cutover atomicity becomes a **property of the transaction**, not a protocol to design — D-03 dissolves; multi-key cutovers (D-28) and retirement unwind (D-05) are the same one-transaction shape. +* Coverage/gap checks read owned tables directly; the fail-closed-on-stale-mirror degradation mode disappears (there is no mirror to go stale). +* The gear gains a scheduled background job (activation/expiration) — the coordination-lease library was already an internal dependency for read-model warm re-drive. +* `PriceWindow*` consumers (Rating caches, Tariffs) are unaffected: names, ordering, and delivery semantics unchanged; the manifest §4.1 producer note flips from "consumed" to "produced" here. +* The scheduler/timeline **UI** concerns from the legacy UC land in the Frontend DESIGN, not here. +* If a future artifact class ever needs generic effective-dating, it builds its own — *price* windows are not a shared platform service (nothing consumed them but pricing). + +### Confirmation + +* Design review: no mirror/`window_ref`/UC-contract references remain in the design set; S7 owns the table, state machine, job, and events; the S5 endpoint map covers the window API. +* Integration test: a simulated failure on the successor-schedule step of a cutover rolls back the shorten and both schedules — no partial window state at any instant; lease takeover activates a due window exactly once. +* Cross-team: Rating confirms `PriceWindow*` consumption is unchanged; Architecture acks the §15 answer (no standalone effective-dating PRD). + +## Pros and Cons of the Options + +### Atomic batch window operation in the UC contract + +Ask the UC's (hypothetical) owner to expose shorten+schedule+schedule as one atomic call. + +* Good, because it keeps the manifest's original component split and gives true atomicity. +* Bad, because the UC has **no runtime owner to ask** — the request would first have to invent the component, for a single consumer. +* Bad, because the mirror machinery (lag alarm, fail-closed degradation, D-26 routing question) survives. + +### Saga with compensation over the external UC + +Order the operations (schedules first is impossible — the successor overlaps the still-long +predecessor; so shorten first), compensate on failure before the cutover instant. + +* Good, because it needs no new UC capability beyond cancel + **extend** (un-shorten). +* Bad, because "extend back" is itself a new contract capability, and a failed compensation leaves the latent gap guarded only by an alarm and an operator's reaction time until T. +* Bad, because every future multi-window operation (multi-key cutover, retirement unwind) re-inherits the saga complexity. + +### Consolidate window ownership into the pricing gear (chosen) + +* Good, because atomicity, coverage safety, and the D-05/D-26/D-28 simplifications fall out of one move; the artificial distribution and its compensating machinery disappear. +* Good, because it matches the confirmed topology (one gear, one database) and the factual consumer set (only pricing). +* Neutral, because the gear takes on the activation job and the window API surface — bounded, and the lease/authz/audit infrastructure already existed. +* Bad, because the manifest's original component sketch is superseded — accepted; the §15 question existed precisely to settle this, and the event contract (the real cross-team surface) is preserved. + +## More Information + +Full decision record: D-03 in [`../DECISIONS.md`](../DECISIONS.md) (incl. the source-UC +investigation); the legacy UC carries a consolidation banner +(`vhp-architecture/docs/bss/prd/PRD-product-catalog-marketplace-202601120119/UC-effective-dating-price-windows-202601121200.md`). + +## Traceability + +- **PRD**: §2.1 (boundary note), §9.2 (`cpt-cf-bss-pricing-contract-pricewindow` — internalized), §13 (dependency row), §15 (consolidation question — answered), §4 event alignment (producer note) +- **Design**: [`../design/07-pricewindow-linkage.md`](../design/07-pricewindow-linkage.md) (owner: store, state machine, API, job, events); [`../design/01-foundation.md`](../design/01-foundation.md) §1.2 (event set), §3.4/§3.5; [`../design/11-lifecycle.md`](../design/11-lifecycle.md) (retirement invokes the owned flow); [`../design/12-operator-efficiency.md`](../design/12-operator-efficiency.md) (bulk window ops are local writes) +- **Related ADRs**: `cpt-cf-bss-pricing-adr-canonical-scope-key` (the non-overlap key), `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` (per-generation windows) diff --git a/gears/bss/pricing/docs/DECISIONS.md b/gears/bss/pricing/docs/DECISIONS.md new file mode 100644 index 000000000..01eb71b8a --- /dev/null +++ b/gears/bss/pricing/docs/DECISIONS.md @@ -0,0 +1,468 @@ + + + +# Open Design Decisions — Plan & Price Modeling + + + +- [How to use this document](#how-to-use-this-document) +- [Status board](#status-board) +- [A. Design-lock blockers](#a-design-lock-blockers) +- [B. Governance](#b-governance) +- [C. Plan & price shape (S2/S3)](#c-plan--price-shape-s2s3) +- [D. Consumer contracts & windows (S6/S7)](#d-consumer-contracts--windows-s6s7) +- [E. Bundles, price overlays, primitives (S8/S9/S10)](#e-bundles-price-overlays-primitives-s8s9s10) +- [F. Lifecycle & operator efficiency (S11/S12)](#f-lifecycle--operator-efficiency-s11s12) +- [G. Ratifications — decisions already applied by the review fix wave](#g-ratifications--decisions-already-applied-by-the-review-fix-wave) + + + +## How to use this document + +Source: the 2026-07-09 design-review wave (7 parallel review passes over all 15 docs; the +~50 mechanical fixes were applied directly — this file holds only what needed an actual +decision). **Status: all 39 decisions and all 14 ratifications are closed as of 2026-07-10** +and propagated into the named docs; each entry records the decision, rationale, and the +propagation surface. Items marked *(autonomous)* were decided by the reviewing agent under a +standing mandate ("decide where the call is technical"); three carry product flavor and are +explicitly **flagged for veto**: D-23 (rule-based change targets cut from launch), D-34 +(in-flight migration cancel added), D-39 (migration never grants a new trial). Reopening any +item = flip its status and record why. + +Severity: **[H]** breaks money/correctness or is unimplementable as written · **[M]** teams +can build incompatible behavior · **[L]** contained. + +## Status board + +| # | Sev | Title | Status | +|---|-----|-------|--------| +| D-01 | H | S4 tax-completeness check has no data source | **DECIDED 2026-07-10** | +| D-02 | H | Second grandfathering cutover on the same scope key is impossible | **DECIDED 2026-07-10** | +| D-03 | H | Cutover atomicity across the external window SoR | **DECIDED 2026-07-10** | +| D-04 | H | Coverage hole between `grandfatherUntil` expiry and renewal re-bind | **DECIDED 2026-07-10** | +| D-05 | H | Retirement cancels the scheduled halves of an approved cutover | **DECIDED 2026-07-10** | +| D-06 | H | Overlay/membership read-model propagation has no publish trigger | **DECIDED 2026-07-10** | +| D-07 | H | Rev-share: exact shares vs tolerant shares (PRD AC #23 conflict) | **DECIDED 2026-07-10** | +| D-08 | H | `fixed`/amount PriceOverlay adjustments have no currency axis | **DECIDED 2026-07-10** | +| D-09 | H | Payer in multiple customer groups: resolution undefined | **DECIDED 2026-07-10** | +| D-10 | M | Threshold-policy mutation is itself ungoverned | **DECIDED 2026-07-10** | +| D-11 | M | Bundle publish authz: no role can execute the stated conjunction | **DECIDED 2026-07-10** | +| D-12 | M | Finance access to history/audit reads vs the role matrix | **DECIDED 2026-07-10** | +| D-13 | M | Historical-import governance (pipeline + second person) | **DECIDED 2026-07-10** | +| D-14 | L | G4 WORM vs hash chain: transactional-audit claim | **DECIDED 2026-07-10** | +| D-15 | H | Phase→price coverage rule missing (empty phase publishes) | **DECIDED 2026-07-10** | +| D-16 | M | Add-on dependency/conflict edges have no data model | **DECIDED 2026-07-10** | +| D-17 | M | Who sets `fail_closed_top` | **DECIDED 2026-07-10** | +| D-18 | M | Tiered non-usage rows: legal or forbidden | **DECIDED 2026-07-10** | +| D-19 | M | `phase` scope-key axis typing (literal vs `phase_id`) | **DECIDED 2026-07-10** | +| D-20 | M | `customEveryN Months(n)` anchor semantics | **DECIDED 2026-07-10** | +| D-21 | L | Save-time vs publish-time validation split (AC #12) | **DECIDED 2026-07-10** | +| D-22 | L | What the supersession-continuity fixture gates | **DECIDED 2026-07-10** | +| D-23 | M | Rule-based `allowedChangeTargets` defeat publish-time guarantees | **DECIDED 2026-07-10** | +| D-24 | M | Retired target left in the change graph | **DECIDED 2026-07-10** | +| D-25 | M | Cross-boundary change edges neither rejected nor marked | **DECIDED 2026-07-10** | +| D-26 | M | Must UC-side window mutations route through Slice 7 | **RESOLVED by D-03** | +| D-27 | M | Resolved grant-set drift after a registry tier-policy change | **DECIDED 2026-07-10** | +| D-28 | L | Multi-key (batch) cutover shape | **DECIDED 2026-07-10** | +| D-29 | M | Prepaid GA-gate mechanics (derive / scope / clear) | **DECIDED 2026-07-10** | +| D-30 | M | `ResolvedGroupFreezer` ownership (catalog vs Tariffs) | **DECIDED 2026-07-10** | +| D-31 | M | Retiring a plan targeted by a `PriceOverlay` | **DECIDED 2026-07-10** | +| D-32 | L | Composite-meter output-unit ownership | **DECIDED 2026-07-10** | +| D-33 | L | F-34 member-scoped preview as a tracked GA gate | **DECIDED 2026-07-10** | +| D-34 | H | Cancelling an `in_progress` migration | **DECIDED 2026-07-10** | +| D-35 | H | Bulk/repricing rows vs pending approval units on one key | **DECIDED 2026-07-10** | +| D-36 | M | Execution-time re-validation of locks + boundary deltas | **DECIDED 2026-07-10** | +| D-37 | M | Bulk-lock crash/timeout release path | **DECIDED 2026-07-10** | +| D-38 | L | Migration-cancellation propagation to Subscriptions | **DECIDED 2026-07-10** | +| D-39 | M | Migration entry phase on a phased target (trial re-entry) | **DECIDED 2026-07-10** | +| D-40 | M | `tierQualificationWindow` — trailing-tier qualification (third window) | **DECIDED (autonomous) 2026-07-12 · flagged for veto** | +| D-41 | M | Phase-scoped entitlement grant set (`phase→grant-set map`) | **DECIDED (autonomous) 2026-07-12 · flagged for veto** | +| D-42 | H | PriceOverlay: single adjustment → per-plan adjustment lines (reopens F-88) | **PROPOSED 2026-07-13 · flagged for veto** | +| D-43 | M | Prepaid grant: `category` + `applicability` + Billing-owned drawdown order (Stripe parity) | **DECIDED (autonomous) 2026-07-14 · flagged for veto** | +| D-44 | H | Level-based billing in launch: authorable `aggregationFunction {sum, peak, time_weighted}` + `aggregationGranularity` (supersedes F-40 "not at launch") | **DECIDED (product call) 2026-07-16 · CONFIRMED 2026-07-16** | +| D-45 | M | First-class `includedAllowance {quantity, rolloverPolicy}` in launch, publish-compiled (`none` → $0 band + marker; `carry` → D-43 grant) — supersedes F-32 | **DECIDED (product call) 2026-07-16 · CONFIRMED** | +| D-46 | M | Registry `sellable` flag on SKU (products gear) + sellability-gate predicate 6 (standalone lines; components exempt) | **DECIDED (product call) 2026-07-16 · CONFIRMED** | +| R-01…R-14 | — | Ratify the fix wave's applied decisions (section G) | **ALL CONFIRMED 2026-07-10** | + +## A. Design-lock blockers + +#### D-01 [H] S4 tax-completeness check has no data source + +- **Where**: [`design/04-currency-tax.md`](./design/04-currency-tax.md) C4 + `TaxDisplayValidator` + §6; PRD §17.4 "Tax-inclusive" row. +- **Problem**: C4 blocks publish on "`taxInclusive=true` without a region tax rate" and "`taxInclusive=false` in a region with no configured `taxCategory`" — but no table/config anywhere stores per-region tax rates or per-region `taxCategory` (Tax Engine, which owns rates, is post-MVP). The validator's key predicate has no input. Worse, under the default fail-closed policy the flagship AC ("mixed plan publishes with `not_sellable_ga` on the EU rows") is unreachable — at MVP *every* region lacks a rate, so the row would block, not flag. +- **Options**: (a) add a per-region tax config (rate-present marker + `taxCategory`) to the region-taxonomy rows (or a `pricing_policy_object` entry), name it as the C4 input, and state the ordering — C4 completeness evaluates against tenant config; a *passing* `taxInclusive=true` row still publishes flagged `not_sellable_ga` (C3); (b) drop C4's rate predicate at MVP entirely (keep only `taxCategory` completeness), reinstate when Tax Engine lands. +- **Recommendation**: (a) — keeps the PRD §17.4 predicate honest and makes the AC constructible. +- **Decision**: **(a), 2026-07-10** — as the `RegionTaxReadiness` **port**: `(tenant, region) → { taxCategory, ratePresent }`, fail-closed on unknown. MVP provider = tenant-declared columns `tax_category` + `tax_rate_present` on `pricing_region_taxonomy` (CatalogAdmin, `config × write`, audited; catches configuration mistakes — rate correctness is unverifiable pre-Tax-Engine). Post-GA provider = Tax Engine-backed (sync vs event-fed mirror + the contract land in the Tax Engine PRD); tenant markers are then reconciled — divergence flags published rows + `pricing.tax.readiness_divergent` (Warn), remediation = re-publish. Ordering: C4 readiness gate precedes the C3 `not_sellable_ga` flag. **Propagated**: S4 C4/§1.7/`algo-tax-display` (`inst-td-readiness`)/§6/§9/§10; PRD fr-tax-display-basis + §17.4 (both rows). + +#### D-02 [H] Second grandfathering cutover on the same scope key is impossible + +- **Where**: [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) `inst-co-copy`/`inst-co-bounds`, W2; Foundation §4.3; PRD §17.5. +- **Problem**: the copy always lands on the single `existing_grandfathered` eligibility value. A second cutover on the same key (annual reprice, each cohort keeps its price) must schedule copy #2 overlapping copy #1's open-ended window → non-overlap violation, and every escape hatch (supersede the copy, shorten its window under `grandfatherUntil`) is explicitly forbidden. The design supports exactly one grandfathered generation per key and never says so; there is no error code for the rejected second cutover. +- **Options**: (a) make the limit normative — "at most one grandfathered generation per canonical scope key"; a cutover on a key whose grandfathered sibling holds an active/scheduled window is rejected (`CUTOVER_GRANDFATHERED_OCCUPIED`, 409) unless that row's `grandfatherUntil` is at/before the new cutover instant; (b) add a generation discriminator to the eligibility axis (multi-generation cohorts) — needs an ADR + PRD scope-key change. +- **Recommendation**: (a) for launch; (b) is a real product capability question (stacked legacy cohorts) — take it only with a product mandate. +- **Decision**: **(b) in full, laid in now — 2026-07-10** (product call: multi-cohort retention is table stakes; no N=1 launch boundary). **ADR-0002** (`cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`): the canonical scope key gains the additive `cohort` axis = the cutover instant (`none` on non-grandfathered rows; publish-enforced `cohort ≠ none ⇔ existing_grandfathered`). Every cutover creates a **new coexisting generation**; prior generations untouched. Within the grandfathered class, Tariffs selects the row by the **cohort of the subscription's pinned price id** (`pricingSnapshotRef` — no new binding store); most-specific-wins stays class-only. Default-move renewal semantics preserved (the Stripe-model analysis: full per-subscription binding was rejected for inverting the renewal default; industry mapping in the ADR). Note: D-04 (window bound at `grandfatherUntil`) and D-05 (retirement vs pending cutover) now apply **per generation**. **Propagated**: ADR-0002 (new) + ADR-0001 amendment note; Foundation §1.2/§3.7/§4.1/§5; DESIGN §1.2/§2.2/§3.6/§5; PRD glossary (Price row, priceEligibility, Grandfathering, grandfatherUntil, new `cohort` entry), §2.2, fr-grandfathering-eligibility, fr-supersession, AC #8/#71 key tuples, §17.4 duplicate-scope, §17.5 cutover row, §13 Tariffs row; S7 W2/W3, cutover flow/algo (`inst-co-copy`/`inst-co-atomic`/`inst-co-single-pending`), eligibility (`inst-el-fields`/`inst-el-generation`), state machine, DoD, ACs. + +#### D-03 [H] Cutover atomicity across the external window SoR + +- **Where**: [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) `inst-gc-commit`/`inst-co-atomic`, W1 (windows live in the effective-dating UC; the slice holds an event-driven mirror). +- **Problem**: the design claims "shorten + two schedules commit as one transaction", but the three operations execute in another component via the UC contract — a local ACID transaction cannot span them. Partial failure (shorten committed, copy-schedule failed) opens exactly the coverage gap the atomic unit exists to prevent; no compensation, no atomic multi-op UC API, no re-validation at commit is specified. +- **Options**: (a) require the UC contract to expose an **atomic batch window operation** (shorten+schedule+schedule accepted/rejected as one) — cross-team ask; (b) define a saga: schedules first, shorten last, explicit compensation on failure, plus a gap-freeness re-check against fresh UC state inside the commit step. +- **Recommendation**: (a) if the UC team accepts (cleanest, and other batch users exist — D-28); otherwise (b) written out normatively. +- **Decision**: **(c) — consolidation, 2026-07-10.** Investigation of the source UC (`vhp-architecture/docs/bss/prd/PRD-product-catalog-marketplace-202601120119/UC-effective-dating-price-windows-202601121200.md`) showed: it targets the defunct monolithic "Catalog Service"; its content is 100% price windows (no other consumers — registry unmentioned); its normative deltas were already superseded by this PRD; the atomicity requirement was already assigned to it via the consolidate-UC open question. Given the one-gear topology (all slices = one deployable, one PostgreSQL; Tariffs separate), **window ownership moves into the pricing gear**: S7 owns the `pricing_price_window` store + state machine (`scheduled/active/expired/cancelled`), the scheduling/cancellation API, the coordination-lease activation/expiration job, and `PriceWindow*` event production (frozen manifest names). The cutover's multi-window unit becomes a **local ACID transaction** — D-03 dissolves; **D-26 dissolves** (no UC-side mutations exist; mirror + `mirror_lag`/`coverage_gap` alarms removed); D-05 simplifies (single transactional domain). Legacy-UC dispositions: FX rate-lock rejected (no FX in catalog); impact preview out of scope (needs Subscriptions data); `suspended` state not adopted. PRD §15 consolidation question **answered** (formal Architecture ack pending). **Propagated**: S7 (§1, W1, names, flow, `inst-fg-when`, new `state-price-window` + window API/codes/table/events/alarms/DoD/ACs/§10), S11 (retirement invokes S7 flow; table refs), S12 §10 (bulk window ops local), S5 (window-endpoint authz rows), Foundation (§1.2 events, §3.4/§3.5, §4.3), DESIGN/README, PRD (12 spots incl. §2.1 boundary, §9.2 contract internalized, §13 row, §15 answer), legacy UC banner (vhp-architecture). **Formalized as ADR-0003** (`cpt-cf-bss-pricing-adr-pricewindow-consolidation`). + +#### D-04 [H] Coverage hole between `grandfatherUntil` expiry and renewal re-bind + +- **Where**: [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) `inst-co-bounds`/`inst-el-expiry`; PRD fr-grandfathering-eligibility. +- **Problem**: the copy's window must cover only **through** `grandfatherUntil`, but re-bind happens at the **next renewal** — a subscription renewing N days after expiry is bound to a key with no window for N days; usage/arrears rating on that key fails closed for a legitimate subscriber. Alternatively, if the intent is that an expired subscription immediately eligibility-matches the successor, its price changes mid-cycle before any re-bind — stated nowhere. +- **Options**: (a) the copy's window MUST cover `grandfatherUntil` **plus the longest billing cycle sold on that key** (tighten `inst-co-bounds` + PRD); (b) after expiry, Tariffs eligibility-matching resolves the subscription to the successor row even before the Subscriptions re-bind (re-bind is bookkeeping) — mid-cycle price change, needs product sign-off. +- **Recommendation**: (a) — no mid-cycle surprises, purely catalog-side rule. +- **Decision**: **(a), 2026-07-10** — a generation's window MUST cover `grandfatherUntil` + the **longest billing cycle sold on that key** (open-ended when null); enforced at cutover and on every `effectiveTo` adjustment. The margin keeps every bound period rateable until its renewal re-bind; it leaks nothing (new subscriptions never bind grandfathered rows). Post-D-02 the old cost (key occupancy delaying the next cutover) is gone — generations coexist. **Propagated**: S7 `inst-co-bounds` + key constraints + unit AC; PRD fr-grandfathering-eligibility. + +#### D-05 [H] Retirement cancels the scheduled halves of an approved cutover + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-rt-cancel` ("cancel every not-yet-active window; active windows run to their natural end") vs [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) cutover flow. +- **Problem**: after an approved-not-yet-effective cutover, the state is: current window shortened to the cutover instant (active), copy + successor scheduled (not yet active). Retirement cancels both scheduled windows, and "natural end" of the active window was already moved to the cutover instant — in-flight subscribers lose all coverage at that instant. The future-gap check can't see it (it detects gaps *between* windows, not a trailing void). +- **Options**: (a) extend the retirement-triggered review with a **trailing-coverage check**: cancelling a scheduled window is rejected while an active window on the same/sibling eligibility key was shortened by an approved cutover whose instant hasn't passed; (b) retirement **unwinds** the pending cutover (restores the original `effectiveTo`, voids the cutover unit) as part of its approval unit. +- **Recommendation**: (b) — retirement expresses clear intent; unwinding is more operator-predictable than a rejection the operator can't easily interpret. Cross-reference from S11 `inst-rt-cancel` and S7 either way. +- **Decision**: **(b) + always-material, 2026-07-10** — retirement **unwinds** a live cutover unit inside the retirement transaction (one ACID scope post-D-03): predecessor window `effectiveTo` restored to its recorded pre-cutover value, scheduled copy/successor cancelled, unit closed as `unwound` (a merely `submitted` unit is voided per pin semantics); dry-run lists the unwind; prior generations untouched. Retirement with a live cutover is registered **always-material** in the S5 evaluator. **Propagated**: S7 `inst-co-retirement-unwind` (new step 7 of `algo-cutover`); S11 `inst-rt-api`/`inst-rt-cancel` + integration AC; S5 `inst-mat-registered` + threshold DoD; PRD AC #35 (two new And-clauses). + +#### D-06 [H] Overlay/membership read-model propagation has no publish trigger + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) §7 ("overlays/memberships project into the read model **with the next relevant publish/warm**") vs `inst-pl-return` (immediate projection), Foundation §4.4 (read model monotonic per `CatalogVersion`), PRD AC #56. +- **Problem**: PriceOverlays and memberships have no publish/versioning trigger of their own, but the read model Tariffs pins only advances on a plan publish. An authored overlay/membership becomes rateable at an arbitrary later time (possibly never in a quiet tenant); a payer enrolled today can renew tomorrow against a pinned version that predates the membership — freezing the wrong (no) group. Contradicts publish-through-engine. +- **Options**: (a) overlays/memberships **publish through the Foundation engine** (own validation pass + `CatalogVersion` addressability request + warm — AC #56's "list is published" reading); (b) declare them a **separately versioned side-channel** with its own monotonic version + propagation SLO that Tariffs pins alongside `CatalogVersion`, and state which version the frozen snapshot records. +- **Recommendation**: (a) — one propagation mechanism, one determinism story; the registry batching already amortizes version churn. +- **Decision**: **(a), 2026-07-10** — every committed `PriceOverlay`/membership mutation is its **own publish unit through the Foundation engine** (validation → pending `CatalogVersion` ref → warm); consumer visibility is version-pinned exactly like plan content, so a renewal after the commit always sees the membership (the freezing race dissolves). No dedicated event (consumers observe `CatalogVersionPublished` + warmed content); registry batching coalesces bulk-enrollment churn; the overlay/membership trigger line joins the open §15 increment-taxonomy item with Registry. **Propagated**: S9 `inst-pl-return`/`inst-gm-return`/§7 + both DoDs + new integration AC; Foundation §4.2 (publish units are not only plans); PRD fr-priceoverlay-authoring + fr-customer-group-pricing + §17.5 increment table (new row) + §14 registry note. + +#### D-07 [H] Rev-share: exact shares vs tolerant shares + +- **Where**: [`design/08-bundles.md`](./design/08-bundles.md) `inst-rs-residual`/§6/AC ("shares must be exact", `RESIDUAL_UNASSIGNED`) vs PRD AC #23 ("default absorber = the platform… e.g. 33.33%×3 within ≤ 0.01% tolerance"). +- **Problem**: three contradictions in one mechanism: (1) the PRD's own worked example (33.33×3 = 9999 bp) fails the design's exact-sum publish check; (2) the PRD default-absorber (platform) makes the design's `RESIDUAL_UNASSIGNED` unreachable; (3) the platform-as-absorber is unrepresentable in the data model (absorber flag lives on vendor rows; the platform cut is a bare column). +- **Options**: (a) **exact-shares** — amend PRD AC #23 (drop the 33.33×3 authoring example and authoring-time tolerance; tolerance applies to downstream *monetary* rounding only), keep or drop the platform default (if kept: add a platform party row or `residual_absorber = platform` sentinel and delete `RESIDUAL_UNASSIGNED`); (b) **tolerant shares** — relax the design to |Σ−10000| ≤ 1 bp with the nominated absorber taking the delta. +- **Recommendation**: (b) — matches operator reality (percentages come from contracts as 33.33%) and the PRD's intent; keep the absorber explicit-or-platform-default. +- **Decision**: **(b) with publish-time normalization, 2026-07-10** — authoring accepts `|Σ − 10000| ≤ 1 bp` (= the PRD's 0.01%); publish **normalizes** the bundle-level `residual_absorber` (a vendor SKU or the `platform` sentinel; default platform — an unnominated state cannot exist) so published **effective shares sum to exactly 10000 bp**; typed values retained for audit, adjustment recorded; over-tolerance → `RESIDUAL_OVER_TOLERANCE` (422) — a 6-way even split (9996 bp) must be operator-reconciled. Downstream (Tariffs/Marketplace) reads only effective shares — determinism intact; monetary (cent) rounding at settlement stays a separate downstream rule on the same absorber. `RESIDUAL_UNASSIGNED` deleted; `REVSHARE_UNBALANCED` narrowed to structural malformation. Data model: `share_bp` (typed) + `effective_share_bp` (published) on `pricing_bundle_revshare`; absorber moves to `pricing_bundle.residual_absorber`. **Propagated**: S8 (§1.1, B2, `RevShareReconciler`, flow errors, `inst-rs-residual`, §5 codes, §6 tables + constraints, Rev-Share DoD, AC); PRD AC #23 + fr-bundle-composition. + +#### D-08 [H] `fixed`/amount PriceOverlay adjustments have no currency axis + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) `inst-plv-adjustment` + `pricing_price_overlay` columns; PRD fr-priceoverlay-authoring (also silent). +- **Problem**: an overlay's scope (partner/brand/customerGroup/global) spans base rows in many currencies, but `adjustment_value` is a bare minor-unit integer. A `fixed` (or absolute discount/markup) overlay is ambiguous the moment its target sells in ≥ 2 currencies: applying a EUR amount to a USD row is forbidden FX; failing closed on every non-matching market makes the overlay near-useless. Precision validation ("at the currency's minor unit") can't even run without a currency. +- **Options**: (a) amount-based adjustments carry per-`(currency)` (or per-`(currency, region)`) values — a child table like `pricing_grant_price`; (b) an amount-based adjustment MUST declare a single currency and its target coverage is restricted to base rows of that currency (publish-time check + error code). +- **Recommendation**: (a) — mirrors how price rows and grant prices already work; (b) as launch-scope fallback if authoring cost matters. Backfill PRD §6.6 either way. +- **Decision**: **(a), 2026-07-10** — money exists only per-currency, explicitly authored (the same discipline as base rows and grant prices). Percent adjustments stay a single bp value (currency-neutral); amount-based adjustments carry a **`pricing_price_overlay_amount`** value set (`UNIQUE (price_overlay_id, currency)`, minor-unit-validated) that MUST cover **every currency the overlay's target scope sells** — missing currency fails authoring (`ADJUSTMENT_CURRENCY_NOT_COVERED`, 422). Drift (a base row in a new currency published later): flag `coverage_incomplete` + `pricing.priceoverlay.amount_coverage_incomplete` (Warn); the uncovered market resolves **without** the overlay (normal precedence semantics — base price), remediation = add the value. (b) rejected: N same-class overlays would collide on precedence uniqueness. **Propagated**: S9 `inst-plv-adjustment`, §5 code, §6 child table + `adjustment_value` narrowed to bp, §7 alarm, PriceOverlay DoD, unit + integration ACs; PRD fr-priceoverlay-authoring, AC #26 enumeration, AC #55. + +#### D-09 [H] Payer in multiple customer groups: resolution undefined + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) `inst-cg-resolve`/`inst-cg-freeze` + `pricing_group_membership` (non-overlap is per `(payer, group)` only); PRD fr-customer-group-pricing / AC #109 (singular "the resolved group"). +- **Problem**: a payer may hold `trial` and `vip` simultaneously (overlap is rejected only within one group), yet freezing and the tie-break assume exactly one resolved group — two `customerGroup` overlays are the *same* class, so `inst-plv-class-tiebreak` cannot order them. Which adjustment applies and which group freezes is nondeterministic — a money outcome. +- **Options**: (a) enforce **at most one active membership per payer across all groups** (widen the non-overlap constraint; reject cross-group overlap at enrollment); (b) plural freezing (`resolvedGroups[]`) + a deterministic selection/stacking rule Tariffs adopts (e.g. per-group `precedence`, one overlay wins; document in PRD §17.7). +- **Recommendation**: (a) for launch — smallest surface, deterministic by construction; groups-as-segments rarely need stacking (discount stacking belongs to Promotions). +- **Decision**: **(a), 2026-07-10** — at most **one active membership per payer across all groups**, enforced at write; a conflicting enrollment → `MEMBERSHIP_CONFLICT` (409, names the active membership); a transfer is the atomic **move** operation (`POST …/members/{payerId}/move` — end current + start new at one instant, audited, standard materiality rules). "The resolved group" stays a truthful singular everywhere; in the catalog, groups exist only for pricing overlays, so two concurrent segments = two concurrent money rules — exactly the ambiguity removed; multi-membership + winner/stacking rule recorded in §17.8 Future (revisit with Promotions). **Propagated**: S9 `inst-cg-resolve`, `inst-ms-move` (state machine), move API row, `MEMBERSHIP_CONFLICT` code, membership-table constraint, customer-group DoD, unit AC; PRD fr-customer-group-pricing, §17.7 Membership row, AC #109 And-clause, §17.8 Future row. + +## B. Governance + +#### D-10 [M] Threshold-policy mutation is itself ungoverned + +- **Where**: [`design/05-governance.md`](./design/05-governance.md) `PUT /v1/pricing/config/approval-threshold-policy` (ETag only; FinanceReviewer). +- **Problem**: the slice's own threat model ("a config admin must not weaken its own approval thresholds") stops at CatalogAdmin — but a **lone FinanceReviewer** can raise all thresholds to ∞ with one audited PUT, after which an accomplice's changes auto-publish approver-free. The most leverage-bearing governance object is the one mutation exempt from the two-person mechanism. (The fix wave already pinned: materiality is evaluated once at submit — a policy change never re-evaluates pending approvals.) +- **Options**: (a) route `approval_policy` PUTs through the approval workflow (always material; approver ≠ the policy submitter); (b) accept the risk explicitly: an alarm (`pricing.governance.threshold_weakened`, Warn, fired on any threshold increase) + a normative risk note. +- **Recommendation**: (a) — it is exactly the two-person rule's home turf; the workflow already exists. +- **Decision**: **(a), 2026-07-10** — the threshold-policy PUT opens an **always-material approval unit** (registered trigger): the diff applies only after an **independent** second FinanceReviewer approves; **direction-agnostic** (any policy diff — no fragile "was it weakening?" computation on multi-currency diffs); standard pin/void semantics apply; in-flight submissions keep their submit-time materiality (already pinned). Bootstrap fail-safe: a single-reviewer tenant leaves the policy unset ⇒ everything material. **Propagated**: S5 `inst-mat-registered`, AuthZ-catalog SoD note, §5 API row, Threshold DoD, new integration AC; PRD fr-approval-threshold-policy + §17.6 row. + +#### D-11 [M] Bundle publish authz: no role can execute the stated conjunction + +- **Where**: [`design/05-governance.md`](./design/05-governance.md) endpoint mapping ("publish of a bundle = `bundle × write` + `plan × publish`") + role matrix (FinanceManager: `plan × publish` but only `bundle × read`; ProductManager: `bundle × write` but no `plan × publish`) vs [`design/08-bundles.md`](./design/08-bundles.md) (names finance-manager/catalog-admin as the publishing actors). +- **Problem**: under the conjunction only CatalogAdmin can publish a bundle; S8 promises FinanceManager can. One of the three statements is wrong. +- **Options**: (a) grant FinanceManager `bundle × write`; (b) publish endpoint requires `plan × publish` only (composition was already authored under `bundle × write`); (c) introduce `bundle × publish` as its own action. +- **Recommendation**: (b) — publish is a plan-level act; authoring authority was already exercised and pinned by the approval hash. +- **Decision**: **(b), 2026-07-10** — the bundle publish endpoint requires **`plan × publish` only**; authoring stays `bundle × write` (ProductManager/CatalogAdmin); the composition is protected at publish time by the approval content pin, and component checks are validations, not caller authz. Role matrix unchanged; S8's actor claim (FinanceManager publishes) becomes true. **Propagated**: S5 endpoint mapping (row split), S8 flow actor line. + +#### D-12 [M] Finance access to history/audit reads vs the role matrix + +- **Where**: [`design/05-governance.md`](./design/05-governance.md) `inst-au-read` ("Auditor/Finance filters") + [`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md) `HistoryExporter` — vs the matrix (`audit × read/export`: Auditor **only**). +- **Problem**: two docs promise Finance a surface the normative matrix denies; implementers will quietly widen the grant or Finance is locked out. +- **Options**: (a) add `audit × read` to FinanceReviewer (and optionally FinanceManager); (b) change both docs to "Auditor filters" only — Finance uses the price-history read (`plan × read`) instead, and `/v1/pricing/history` splits from `/v1/pricing/audit`. +- **Recommendation**: (b) if price history alone satisfies the Finance use case (it usually does — audit rows carry actor PII discipline); else (a) for FinanceReviewer only. +- **Decision**: **(b) — surface split, 2026-07-10.** The mismatch was a modeling error: price **history** (chronological view over append-only price rows — effective dates, amounts) is plan/price data, not audit data. Remapped: `GET /v1/pricing/history` + `POST /v1/pricing/history/export` → **`plan × read`** (Finance in by construction, nothing new granted); `GET /v1/pricing/audit` (actor trails, before/after, approval decisions) stays **`audit × read/export`, Auditor-only**. Role matrix unchanged; PII boundary intact. **Propagated**: S5 endpoint mapping (rows split) + `inst-au-read`; S12 API table (2 rows), actor table, History & Export DoD, §10 security line. + +#### D-13 [M] Historical-import governance: pipeline + second person + +- **Where**: [`design/05-governance.md`](./design/05-governance.md) backdating flow vs [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-sy-backdate` (synthesis is the sanctioned consumer). +- **Problem**: the import path checks grant + reason + "zero downstream billable effect" — it never states whether imported rows run the Foundation validation pipeline (taxonomy, precision, scope-key duplicates), and requires no approval (one `BackdateGrant` holder). Yet backdated rows shape `migrated-origin` snapshots that rating consumes going forward — the "fraud-adjacent" path is weaker-governed than an ordinary price edit. +- **Options**: (a) imports run the same fail-closed pipeline (minus window scheduling) **and** rows reachable by snapshot synthesis require an approval record; (b) pipeline yes, approval no — provenance + audit is declared the accepted compensating control (with Finance sign-off recorded here). +- **Recommendation**: (a); the volume is low and the blast radius is billing history. +- **Decision**: **(a) — both controls, 2026-07-10.** (1) **Row-shape pipeline**: every imported row runs the fail-closed subset — taxonomies, ISO precision, scope-key uniqueness, model shape (window-coverage/sellability/addressability don't apply to reference rows, which resolve only via synthesis provenance); an import can never create a row regular authoring would reject. (2) **Two-person on all imports** (not "synthesis-reachable only" — reachability is undecidable upfront): historical import registered as an **always-material** trigger; `BackdateGrant` holder submits, independent FinanceReviewer approves, rows land on approval (202 → completion); import volume is migration-era-low, blast radius is billing history. **Propagated**: S5 backdating flow (`inst-bd-pipeline`/`inst-bd-twoperson`, error scenarios, return step), always-material trigger list, Backdating DoD, 2 ACs; PRD fr-historical-import-governance + AC #65; S11 `inst-sy-backdate`. + +#### D-14 [L] G4 WORM vs hash chain: the transactional-audit claim + +- **Where**: [`design/05-governance.md`](./design/05-governance.md) G4 + §10 ("audit writes share the mutation transaction"). +- **Problem**: an external WORM store cannot join the DB transaction — the crash-consistency claim silently forces the hash-chain arm; and nothing says a mutation fails closed when the audit substrate is down. +- **Options**: (a) commit to hash-chained audit rows in the same DB (ledger precedent) — claim holds as written; (b) keep WORM as an option but require write-ahead to the transactional log, asynchronously anchored to WORM, and state: an unavailable audit substrate fails the mutation closed. +- **Recommendation**: (a); it was already the default candidate. +- **Decision**: **(a), 2026-07-10** — **in-DB hash-chained audit rows**, committed inside the mutation's ACID transaction: the §10 crash-consistency claim becomes literally true, and "audit substrate down ⇒ fail closed" dissolves by construction (the audit table *is* the database). Periodic chain-verification job (the `pricing_audit_chain_verified` metric already existed); chain head MAY be **asynchronously anchored** to external WORM/object-lock storage as hardening — never on the mutation path (anchor cadence = implementation knob). **Propagated**: S5 G4, §1.5, `inst-au-tamper`, Audit DoD, §10 risks; PRD fr-audit-completeness + retention NFR + AC (3 "WORM or hash-chained" spots). + +## C. Plan & price shape (S2/S3) + +#### D-15 [H] Phase→price coverage rule missing + +- **Where**: [`design/02-plan-definition.md`](./design/02-plan-definition.md) `PhaseGraph` (`inst-ph-map`…); Slice 7 `CoverageChecker` (row-based, blind to phases); PRD AC #26 required-coverage set. +- **Problem**: a trial→intro→evergreen plan whose `intro` phase has **zero price rows** publishes cleanly (no rows ⇒ no windows ⇒ invisible to every check), then at phase conversion Tariffs resolves nothing and rating fails closed in production — exactly the "sold but unrateable" state S2 §1.2 promises impossible. +- **Decide**: (1) the rule — every phase id MUST be referenced by ≥ 1 published price row in every `(currency, region)` the plan sells (error code, e.g. `PHASE_UNCOVERED`); (2) the **hybrid semantics** — per phase, are both recurring and usage parts required, or are usage rows phase-invariant (one usage row spans phases)? +- **Recommendation**: adopt (1); for (2) declare usage rows **phase-invariant by default** (a usage row with `phase = evergreen` covers all phases unless a phase-scoped usage row exists) — matches how trials usually meter. +- **Decision**: **both parts, 2026-07-10** — (1) every phase id MUST be covered by ≥ 1 published **recurring** row per sold `(currency, region)`; violation → `PHASE_UNCOVERED` (422) in the S2 `PhaseGraph` (`inst-ph-coverage` — the row-based S7 coverage check cannot see a row-less phase). (2) **Usage rows are phase-invariant by default, phase-specific wins**: one usage row covers all phases; an explicit phase-scoped usage row overrides for its phase (a published resolution rule of the most-specific-wins class, adopted verbatim by Tariffs, joint fixture; free trial usage = explicit trial-phase row at 0 — never a silent default). **Propagated**: S2 `inst-ph-coverage`/`inst-ph-usage-invariant` + `PHASE_UNCOVERED` + dod-phases + integration AC; PRD fr-plan-phases, AC #54 (+2 clauses), AC #26 enumeration, glossary "Plan phase". Note: the exact axis value of "the default phase" formalizes in D-19. + +#### D-16 [M] Add-on dependency/conflict edges have no data model + +- **Where**: [`design/02-plan-definition.md`](./design/02-plan-definition.md) `inst-cmp-addons` ("conflicting pairs and dependency cycles fail publish") vs `pricing_plan_addon_rule` (no depends-on/conflicts-with columns). +- **Problem**: the graph cycle/conflict check has no edges to walk — either the columns are missing or the edges are registry-owned and the consumed contract is unnamed. +- **Options**: (a) plan-authored edges: `depends_on_addon_sku_id[]` / `conflicts_with_addon_sku_id[]` (or a `pricing_plan_addon_edge` table); (b) compatibility/dependency edges are registry SKU metadata — name that consumed contract in §1.8 and validate against it. +- **Recommendation**: (b) if the registry already models SKU compatibility (check with registry team); otherwise (a). +- **Decision**: **(a), 2026-07-10** — edges are **plan-authored**: `depends_on_addon_sku_id[]` / `conflicts_with_addon_sku_id[]` on `pricing_plan_addon_rule`, values restricted to the same plan's add-on set (outside-set edge fails), conflicts normalized symmetric, cycle walk over `depends_on`, two required conflicting add-ons fail. Rationale: everything else in that table is already per-plan; no new registry capability blocks launch; registry-intrinsic SKU compatibility (if it ever appears) becomes an **additional** validation input — additive, no migration (noted in S2 §10). **Propagated**: S2 `inst-cmp-addons`, §6 columns, unit AC, §10 note; PRD fr-addon-rules. + +#### D-17 [M] Who sets `fail_closed_top` + +- **Where**: [`design/03-price-structure.md`](./design/03-price-structure.md) `inst-tb-top` ("publish persists the explicit maximum + the marker") vs `CLOSED_TOP_UNMARKED` (422) in §5. +- **Problem**: if the system stamps the marker, the error is unreachable; if the author must supply it as an acknowledgment, the instruction never says so. Also unstated: the marker is invalid on non-top bands. +- **Options**: (a) author MUST set `fail_closed_top = true` on a closed top band (explicit acknowledgment; absence → `CLOSED_TOP_UNMARKED`; publish still emits the advisory warning); (b) system-stamped; delete `CLOSED_TOP_UNMARKED`. +- **Recommendation**: (a) — a closed top is a rating-behavior choice the author should consciously own. +- **Decision**: **(c) — closed tops are forbidden entirely, 2026-07-10** (a sharper option raised in review): the top band MUST be open (`toQty = null`); a closed top fails publish (`TIER_TOP_CLOSED`, 422). Rationale: "price undefined above X" is never the commercial intent — capping usage is an entitlement **quota** (grant set, Subscriptions enforces), a money cap is the per-period fee cap (already Tariffs Future, §17.8), and a different price above X is just another band. Consequences: "sold but unrateable" impossible by construction on tiered rows; the `fail_closed_top` marker, `CLOSED_TOP_UNMARKED`, the publish warning, and the Tariffs/Rating fail-closed-above-max evaluation branch are all **deleted**. Honest loss: a leaked quota now bills overflow at the top-band rate instead of raising loud exceptions — acceptable at launch (the compliance-grade tool is the per-period money cap, Future). Reintroduction path (author-acknowledged marker, the old option (a)) recorded in §17.8. **Propagated**: S3 Q1/§1.2/§1.5/§1.7/`inst-tb-top`/§5 code/§6 column removed/DoD/AC; PRD fr-tier-validation, AC #12, §5.2 flow, §17.4 contiguity row, §17.8 row; DESIGN driver row. + +#### D-18 [M] Tiered non-usage rows: legal or forbidden + +- **Where**: [`design/03-price-structure.md`](./design/03-price-structure.md) `inst-mk-forbidden` (eval-policy fields fail only on `flat` non-usage / `per_unit` rows); PRD §17.1 vs §17.4. +- **Problem**: a `graduated` row with `chargeKind = recurring` falls into no rule: nothing forbids it, nothing defines its `Q` (meaningless without a meter), nothing requires/forbids `tierAggregationWindow` on it. +- **Options**: (a) at launch `graduated`/`volume` are valid on `chargeKind = usage` rows only — `MODEL_KIND_CHARGEKIND_MISMATCH` (422); (b) define tiered-recurring semantics (quantity source = seats? no aggregation window) and extend `inst-mk-forbidden`. +- **Recommendation**: (a) — seat-tiered pricing is representable later as `per_unit` + bands, a Future item; don't invent semantics now. +- **Decision**: **(a), 2026-07-10** — `graduated`/`volume`/`package` are valid **only on `chargeKind = usage`** (`MODEL_KIND_CHARGEKIND_MISMATCH`, 422): the tier machinery presupposes a metered quantity stream; no `Q` semantics exist for non-usage rows. Tiered per-seat (bands over seat count on recurring) recorded in §17.8 Future (requires extending `quantitySource` to banded kinds). Side note ratified in discussion: the two tier-price selection semantics are already first-class — `graduated` = marginal per-band, `volume` = the total-quantity band's rate on all units (Tariffs Variant A only), both fixture-gated. **Propagated**: S3 `inst-mk-chargekind` + §5 code + unit AC; PRD fr-model-kind, §17.6 Model kind row, AC #26 enumeration, §17.8 Future row. + +#### D-19 [M] `phase` scope-key axis typing + +- **Where**: [`design/01-foundation.md`](./design/01-foundation.md) §4.1 (`phase = evergreen`, a reserved literal) vs [`design/02-plan-definition.md`](./design/02-plan-definition.md) `pricing_plan_phase.phase_id` (uuid, "referenced by the `phase` scope-key axis"). +- **Problem**: the axis is a union of one reserved literal and per-plan uuids; whether a phased plan's *terminal* phase rows carry its uuid or the literal is undefined — and duplicate-scope, coverage, and supersession comparisons all hinge on it. +- **Options**: (a) the axis is always a `phase_id`; a well-known reserved `evergreen` phase row is auto-created per plan (the default is that id); (b) the axis value is the phase's kind-qualified slug (`evergreen`, `trial-1`, …) — human-readable, stable across revisions. +- **Recommendation**: (a) — uniform typing, no slug-collision rules; state it in Foundation §4.1 and align S2. +- **Decision**: **(a) refined, 2026-07-10** — the `phase` axis is **always a `phase_id`**; the default axis value = **the plan's terminal `phase_id`**. A phased plan uses its authored terminal phase (no second reserved entity); a non-phased/one-time plan gets **one implicit terminal phase row** (kind `evergreen`) auto-created at plan creation — setup rows ride the same id. The literal `evergreen` survives only as the phase *kind*. D-15's "phase-invariant" usage row = the terminal-phase row (resolution: phase-specific wins, else terminal). Clone copies phase rows with new `phase_id`s and remaps copied rows' axis. **Propagated**: Foundation §3.2 `ScopeKey` + §4.1; PRD §2.2; DESIGN §2.2; ADR-0001 defaults line; S2 `inst-ph-default`/`inst-ph-usage-invariant` + §6 table preamble; S12 `inst-cl-copy`. + +#### D-20 [M] `customEveryN Months(n)` anchor semantics + +- **Where**: [`design/02-plan-definition.md`](./design/02-plan-definition.md) P2/`inst-cs-customfreq` (constrains custom-**days** only); [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) K2 (month-end rule exists only for `fixed_day`). +- **Problem**: for `customEveryN Months(n)` nothing states the allowed anchors, and a `subscription_start` anchor on Jan 31 with monthly-equivalent cycles has no defined Feb date. +- **Proposal**: "`customEveryN Months(n)` MAY anchor `subscription_start` or `calendar_month`; a `subscription_start` anchor beyond the target month's length rolls to the last day of the month (same rule as `fixed_day`, K2); all UTC" — **confirm with Subscriptions** (they execute the math). +- **Decision**: **adopted + no-drift rule, 2026-07-10** — months cycles MAY anchor `subscription_start` or `calendar_month`; the K2 month-end clamp extends to `subscription_start` anchors, and the **anchor day is preserved** across periods (independent per-period clamp: Jan 31 → Feb 28 → Mar 31 — industry-standard no-drift semantics); UTC; rides the joint proration/anchor fixture with Subscriptions. **Propagated**: S2 P2 + `inst-cs-customfreq`; S6 K2 + `inst-pi-anchor`; PRD fr-custom-frequency, AC #45, glossary `billingAnchorPolicy`. + +#### D-21 [L] Save-time vs publish-time validation split + +- **Where**: [`design/03-price-structure.md`](./design/03-price-structure.md) authoring flow (`MODEL_KIND_MISSING` at POST, "full validation defers to publish") vs PRD AC #12 ("**save**/publish MUST fail" for band overlap/gap/ordering). +- **Decide**: which checks run at save (per-field shape: explicit kind on a banded row, precision, scope-key duplicate) vs publish (aggregate: contiguity, fixtures, coverage) — and reconcile AC #12's save-time expectation. +- **Recommendation**: band ordering/overlap/zero-width at **save** (they are row-local and cheap), contiguity/top-band policy at publish; state the split in S3 §2. +- **Decision**: **decided (autonomous), 2026-07-10** — sharper than the recommendation: **all row-local checks run at save and re-run at publish** (kind shape + kind×chargeKind matrix, full band-set geometry incl. contiguity — bands belong to one row, nothing about them is "aggregate" — precision, eval-policy placement, scope-key duplication); **aggregate/cross-entity checks are publish-only** (fixtures, window/phase coverage, hybrid completeness, injectivity). AC #12's save-time expectation satisfied. **Propagated**: S3 `inst-pr-return`. + +#### D-22 [L] What the supersession-continuity fixture gates + +- **Where**: [`design/03-price-structure.md`](./design/03-price-structure.md) fixture registry (the fix wave added `variant = supersession_continuity` with a proposal). +- **Decide**: ratify the proposal — the continuity fixture gates the **first publish of any tiered usage kind** (alongside that kind's own fixture) — or scope it differently (e.g. gate the first *supersession* instead of the first publish). +- **Decision**: **ratified as proposed (autonomous), 2026-07-10** — gating the first *publish* (not the first supersession) is fail-safe: by the time a supersession happens the fixture must already have proven `Q`-continuity, and it keeps one uniform FixtureGate trigger. **Propagated**: S3 §6 registry note. + +## D. Consumer contracts & windows (S6/S7) + +#### D-23 [M] Rule-based `allowedChangeTargets` defeat publish-time guarantees + +- **Where**: [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) `inst-pc-targets`/`inst-pc-mutual`; PRD fr-plan-change-contract. +- **Problem**: "dangling target fails publish" and "every target carries a `comparabilityRank`" are only checkable over an explicit list; for a rule, targets exist at read time — a rule can resolve to an unpublished or rank-less plan, making A→B classification uncomputable with no specified failure mode. +- **Options**: (a) restrict rules to tenants with an authoritative published `PlanTier` ordering (rank always derivable); (b) specify read-time fail-safe semantics: an unresolvable/rank-less rule-resolved target is excluded from the resolved set and the contract is marked `partially_resolvable` in the read model; (c) drop rules at launch (explicit lists only). +- **Recommendation**: (c) at launch, (b) as the designed extension — rules without enforcement teeth are drift generators. +- **Decision**: **(c) + (b)-as-Future (autonomous), 2026-07-10** — launch = **explicit published `planId`s only**; rule-based targets not authorable (a rule resolves only at read time, defeating every publish-time guarantee). The designed extension (read-time fail-safe resolution + `partially_resolvable` marker) recorded in §17.8. *Product-flavored: narrows a PRD-listed option — flagged for veto.* **Propagated**: S6 `inst-pc-targets`; PRD fr-plan-change-contract, glossary, AC #108, §17.8 row. + +#### D-24 [M] Retired target left in the change graph + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-re-references` (guards bundles + add-on overrides only); [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) `inst-pc-targets`. +- **Problem**: after target B retires, plan A still publishes edge A→B; whether Subscriptions may execute a self-service change into a retired plan is undefined (a plan change is not clearly a "purchase" for the sellability gate). +- **Proposal**: (1) add `allowedChangeTargets` referrers to S11's retire dry-run enumeration (as **warn**, not block — a change edge is softer than a bundle reference); (2) one sentence in S6: an edge whose target is later retired is inert — Subscriptions MUST re-check the target's lifecycle state at change time. +- **Decision**: **warn + inert-edge (autonomous), 2026-07-10** — retire dry-run enumerates `allowedChangeTargets` referrers as a **warning** (a change edge is softer than a bundle reference); the edge goes **inert** and Subscriptions re-checks the target's lifecycle state at change time. **Propagated**: S11 `inst-re-references`; S6 `inst-pc-targets`; PRD fr-plan-change-contract. + +#### D-25 [M] Cross-boundary change edges neither rejected nor marked + +- **Where**: [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) K3/`inst-pi-crossboundary` vs `inst-pc-targets` (no boundary check); contrast S11 where the same mismatch is a blocking migration delta. The commonest self-service upgrade (monthly→annual) crosses frequency. +- **Options**: (a) publish-time classification of each edge as `in_place` vs `cancel_plus_new` (published on the edge; storefront can disclose credit forfeiture); (b) reject `cancel_plus_new` edges at publish until the K3 cross-team sign-off lands. +- **Recommendation**: (a) — the sign-off (PRD §15) needs the classification anyway; publishing it is the catalog's half of the contract. +- **Decision**: **(a) (autonomous), 2026-07-10** — publish classifies every edge `in_place` vs `cancel_plus_new` and publishes it on the edge; Subscriptions/storefront disclose credit forfeiture before execution; re-computed on either side's re-publish. **Propagated**: S6 `inst-pc-boundary`; PRD fr-plan-change-contract, glossary, AC #108. + +#### D-26 [M] Must UC-side window mutations route through Slice 7 + +- **Where**: [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) `inst-fg-when` — the fix wave added mirror-event re-checks + the `pricing.window.coverage_gap` Critical alarm (detection). Routing is the open half. +- **Decision**: **RESOLVED by D-03 (2026-07-10)** — windows are gear-owned; no UC-side mutation path exists. Every window mutation goes through `WindowScheduler`/`CutoverOrchestrator` with in-transaction coverage validation; the mirror, `mirror_lag`, and `coverage_gap` alarms are removed (the window tables carry the same REVOKE + column-whitelist trigger discipline as `pricing_price`). + +#### D-27 [M] Resolved grant-set drift after a registry tier-policy change + +- **Where**: [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) `inst-gs-resolved` (frozen resolved set) — the registry can change a `PlanTier`'s feature/quota policy after publish; the tier-*value* drift got a flag+alarm (S2 `inst-cmp-tier-drift`), the tier-resolved *grants* did not. +- **Proposal**: mirror the S2 mechanism — consume the registry tier-policy-change signal, flag affected published plans `grants_divergent` (+ alarm), remediation = re-publish (re-resolving the set); consumers keep the frozen set meanwhile. Needs the registry signal to carry policy-level changes (joint-contract scope, PRD §15). +- **Decision**: **adopted as proposed (autonomous), 2026-07-10.** **Propagated**: S6 `inst-gs-drift` + `pricing.contracts.grants_divergent` alarm; registry-signal scope noted for the §15 joint contract. + +#### D-28 [L] Multi-key (batch) cutover shape + +- **Where**: [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) (idempotency per `(planId, scope key, instant)`); [`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md) (mass repricing excludes grandfathered rows and creates supersessions, not cutovers). +- **Problem**: "raise prices everywhere, grandfather everyone at T" decomposes into one cutover + one two-person approval per `(currency, region, phase, chargeKind)` key, with no cross-key same-instant consistency guarantee. +- **Options**: (a) a multi-key cutover payload in S7 (one approval unit spanning the plan's keys at one instant); (b) an S12 "mass cutover" run type; (c) accept N units at launch. +- **Recommendation**: (a) — it is the natural extension of the existing unit and reuses the S5 per-row hash pin. +- **Decision**: **(a) (autonomous), 2026-07-10** — the cutover payload carries a **scope-key selector**: all selected keys cut over at one instant as **one approval unit / one local ACID transaction** (post-D-03), per-key generations created, every touched key pended, S5 per-row hash pin covers the set; idempotency per `(planId, key-set hash, instant)`. **Propagated**: S7 API row; PRD §17.5 cutover row. + +## E. Bundles, price overlays, primitives (S8/S9/S10) + +#### D-29 [M] Prepaid GA-gate mechanics + +- **Where**: [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) `inst-pg-gagate` ("the same flag mechanism as Slice 4's `not_sellable_ga`") — but S4's derivation input (`taxInclusive` on the row) and granularity (per market) don't transfer to a plan-attached grant. +- **Decide**: (1) the deriving input — a named platform/tenant GA signal "prepaid balance execution GA" (owner: Billing/Rating, tracked on the program board per PRD §13); (2) the scope — the flag applies to **every scope key of the grant-bearing plan** (matches the PRD AC #87 plan-level clarification); (3) clearing — like S4: re-publish through the pipeline once the signal is GA, never a silent flip (+ a one-line state machine in S10 §4). +- **Recommendation**: adopt (1)–(3) as written. +- **Decision**: **adopted (autonomous), 2026-07-10** — (1) derives from the named platform/tenant signal "prepaid balance execution GA" (owner Billing/Rating, program board); (2) plan-level: every scope key of the grant-bearing plan (matches AC #87); (3) clearing = re-publish through pipeline + approval (S4 `inst-td-clear` pattern), never a silent flip. **Propagated**: S10 `inst-pg-gagate`. + +#### D-30 [M] `ResolvedGroupFreezer` ownership + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) §1.7/`inst-gm-return` — a catalog component "freezes the resolved group into snapshots", but the resolved group is per-payer, resolved at activation/renewal by Tariffs, and the composition SoR is Tariffs; the catalog has no per-subscription snapshot participation and no resolve-for-payer API. +- **Options**: (a) re-scope `ResolvedGroupFreezer` as the **joint contract definition**: catalog publishes membership into the read model; Tariffs performs interval resolution and freezes the group into the snapshot it composes (adjust §1.7, `inst-gm-return`, DoD); (b) add a catalog resolution endpoint Tariffs calls (new API + latency contract in §5). +- **Recommendation**: (a) — keeps the composition SoR where every other doc puts it. +- **Decision**: **(a) (autonomous), 2026-07-10** — `ResolvedGroupFreezer` re-scoped as the **joint contract name**: the catalog publishes membership into the read model; **Tariffs** resolves at activation/renewal and freezes the group into the snapshot **it composes** (composition SoR); no catalog resolve-for-payer endpoint. **Propagated**: S9 §1.7 naming row + `inst-gm-return`. + +#### D-31 [M] Retiring a plan targeted by a `PriceOverlay` + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-re-references` (bundles + add-on overrides only); [`design/09-price-overlays.md`](./design/09-price-overlays.md) `inst-plv-referential` (authoring-time only). +- **Problem**: the retire path silently re-creates the forbidden dangling-overlay state; an overlay targeting a retired plan sits in the read model with undefined Tariffs behavior and no alarm (contrast `pricing.discount.ref_dangling`). +- **Options**: (a) add overlay `target_ref`s to the retire guard (`RETIRE_PLAN_REFERENCED`) — blocks retirement until overlays are ended; (b) dangle-and-remediate: Warn alarm `pricing.priceoverlay.target_retired` + read-model flag, mirroring the discountRef pattern (in-flight subscribers keep resolving retired plans anyway). +- **Recommendation**: (b) — an overlay is an adjustment on top of rows that legitimately outlive retirement for in-flight subscribers; blocking is disproportionate. State the choice in S9 §10 + S11. +- **Decision**: **(b) dangle-and-remediate (autonomous), 2026-07-10** — read-model flag + `pricing.priceoverlay.target_retired` (Warn), overlay stays evaluable for in-flight subscribers; retire dry-run enumerates targeting overlays as a warning; remediation = end/retarget. **Propagated**: S9 `inst-plv-referential` + §7 alarm; S11 `inst-re-references`. + +#### D-32 [L] Composite-meter output-unit ownership + +- **Where**: [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) (persists `output_unit` as a bare string); PRD Glossary (registry declares base units; silent on derived units). +- **Options**: (a) the derived unit is declared to the registry like any `meteringUnit` (Rating recognizes it via the registry); (b) it is a catalog-namespaced id published in the read model only. +- **Recommendation**: (a) — one meter namespace; avoids a parallel identity scheme downstream. +- **Decision**: **(a) (autonomous), 2026-07-10** — the derived output unit is **registry-declared like any `meteringUnit`** (Rating recognizes it via the same registry lookup); the catalog persists the registry unit id + the formula binding; part of the registry joint contract (§15). **Propagated**: S10 `inst-cm-output-unit`; PRD glossary (derived meter). + +#### D-33 [L] F-34 member-scoped preview as a tracked GA gate + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) `inst-plv-member-preview` ("REQUIRED for storefront UX") vs PRD §15 F-34 ("Open. Owner: Tariffs + GTM"). +- **Decide**: either register F-34 as a program-board GA gate (owner + target date; the S9 wording becomes "required **before** restricted segment pricing goes self-service — GA-gated on F-34"), or drive F-34 to Answered now. +- **Recommendation**: register the GA gate; don't hold the slice on it. +- **Decision**: **GA gate registered (autonomous), 2026-07-10** — F-34 stays Open with Tariffs+GTM but is now a **tracked program-board GA gate**: restricted segment pricing does not sell self-service until it lands; nothing else holds. **Propagated**: S9 `inst-plv-member-preview`; PRD §15 F-34 row. + +## F. Lifecycle & operator efficiency (S11/S12) + +#### D-34 [H] Cancelling an `in_progress` migration + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) transition 2's parenthetical ("cancellation of `in_progress` only stops further processing") vs the state machine (no `in_progress → cancelled`), the DELETE contract (before-effective only), `MIGRATION_ALREADY_EFFECTIVE` (409), M3, and the AC. +- **Problem**: mid-batch stop of a partially-executed migration is the risky operational case; the doc half-promises it in a parenthetical while every normative surface forbids it. (Left as-is by the fix wave — this is the decision.) +- **Options**: (a) no cancel once `in_progress` — delete the parenthetical; an operator's only lever is the completion report + a follow-up migration back; (b) add `in_progress → cancelled` (stops further `PlanLink` processing; already-migrated unaffected; partial set listed on the record) and rescope DELETE/`MIGRATION_ALREADY_EFFECTIVE` to `completed` runs only. +- **Recommendation**: (b) — a stop-the-bleeding control on a fan-out that can span thousands of subscriptions is worth the extra transition; pairs with D-38. +- **Decision**: **(b) (autonomous), 2026-07-10** — `in_progress → cancelled` added (halts further `PlanLink` processing; already-migrated unaffected; partial sets listed); only `completed` is uncancellable; DELETE rescoped; `MIGRATION_ALREADY_EFFECTIVE` → `MIGRATION_COMPLETED` (409). *Operational-control flavored — flagged for veto.* **Propagated**: S11 state machine (`inst-mst-cancel-inflight`), API row, §5 code, AC; PRD fr-scheduled-migration. + +#### D-35 [H] Bulk/repricing rows vs pending approval units on one key + +- **Where**: PRD fr-supersession ("at most one pending approval unit per canonical scope key"); [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) `inst-co-single-pending`; [`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md) (bulk import + mass repricing create per-key supersessions under one batch approval — the interplay is unspecified). +- **Decide**: (1) a batch row whose scope key already holds a pending interactive unit — fails Phase-1 validation (or is a listed per-row conflict)? (2) does a **submitted batch approval count as the pending unit** for each contained key (interactive submits on those keys 409 naming the bulk operation, mirroring the bulk lock)? +- **Recommendation**: yes to both: batch row → per-row conflict at Phase-1 naming the pending unit; a submitted batch pins its keys (409 for interactive submits) — symmetric, and the per-row hash pin already gives the batch that identity. +- **Decision**: **both (autonomous), 2026-07-10** — Phase-1 per-row failure naming the pending interactive unit (import + repricing selectors alike, `inst-mp-pending`); a `submitted` material batch **pins every contained key** (interactive submit → 409 `PENDING_CHANGE_UNIT_EXISTS` naming the bulk op). **Propagated**: S12 `inst-bk-phase1`/`inst-bk-approval-subset`/`inst-mp-pending`; PRD AC #112. + +#### D-36 [M] Execution-time re-validation of locks + boundary deltas + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-cl-source` (locks resolve "at validation time"), `delta_report` ("at schedule time"); M5 default notice 60–90 days. +- **Problem**: between scheduling and `effective_at`, a new contract can lock an in-scope subscription (it would be migrated — breaking "a lock is never broken"), and the target's coverage for a frozen `(currency, region)` can lapse. The strongest guarantee is enforced at a point that can be months stale. +- **Options**: (a) on `scheduled → in_progress` the catalog re-resolves the lock set and boundary deltas — newly-locked subscriptions are excluded (appended to the completion record); a newly-broken boundary delta fails that subscription's `PlanLink` closed into the exception list; (b) assign the execution-time re-check to Subscriptions as part of the `PlanLink` joint contract (§10 open item). +- **Recommendation**: (a) for locks + boundary (catalog owns both inputs), with the per-subscription enforcement handshake documented in the joint contract either way. +- **Decision**: **(a) (autonomous), 2026-07-10** — on `scheduled → in_progress` the catalog re-resolves the lock set (newly-locked excluded, appended to the record — a lock is never broken however stale the schedule) and the boundary deltas (a target that lost coverage fails that subscription's `PlanLink` closed into the exception list). **Propagated**: S11 `inst-mst-start` + `inst-cl-source` + AC; PRD fr-scheduled-migration. + +#### D-37 [M] Bulk-lock crash/timeout release path + +- **Where**: [`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md) — the lock releases only via state-machine completion; a crash mid-`committing` freezes interactive authoring on the marked rows indefinitely (only a Warn alarm exists). +- **Proposal**: (1) the bulk runner holds a **coordination lease** (the library DESIGN.md §3.8 already names); on lease takeover the successor re-drives Phase-2 from the journal/report; (2) an operator **abort** transition `committing → completed_with_conflicts` (uncommitted rows reported as not-attempted; lock cleared). +- **Decide**: confirm lease-resume + abort; or abort-only (simpler, loses auto-resume). +- **Decision**: **lease-resume + abort (autonomous), 2026-07-10** — the runner holds a coordination lease (takeover re-drives Phase 2 from the journal, idempotent); operator `:abort` transitions `committing → completed_with_conflicts` (uncommitted rows `not-attempted`, lock cleared). A crashed import can never freeze authoring indefinitely. **Propagated**: S12 §6 bulk-lock note + `inst-bs-abort`. + +#### D-38 [L] Migration-cancellation propagation to Subscriptions + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-mg-cancel` (read-model state + audit; no event) — Subscriptions creates `PlanLink`s on the scheduled event (push) but learns of cancellation only by re-reading (pull); the T-ε race (cancel accepted moments before execution starts) is unhandled. +- **Options**: (a) pull obligation in the §10 joint contract: Subscriptions MUST re-read the schedule state immediately before beginning execution; the catalog's cancel is rejected (`MIGRATION_ALREADY_EFFECTIVE`) once Subscriptions has reported execution start — the state handshake, not the wall clock, is the arbiter; (b) introduce a cancellation event (amends the frozen event set — §7 currently forbids it). +- **Recommendation**: (a) — no event-set change; the handshake also serves D-34(b). +- **Decision**: **(a) (autonomous), 2026-07-10** — a **state handshake, not a wall clock**: Subscriptions re-reads the schedule state before beginning execution and per processing batch thereafter; it never starts/continues against a cancelled record (closes the T-ε race and serves D-34's in-flight cancel); no new event name. **Propagated**: S11 `inst-mg-cancel`. + +#### D-39 [M] Migration entry phase on a phased target (trial re-entry) + +- **Where**: [`design/11-lifecycle.md`](./design/11-lifecycle.md) `inst-mg-boundary` (covers the setup row only); S2 phase machinery is silent on `PlanLink` entry. +- **Problem**: a migrated subscription entering a target plan's `trial`/`intro` phase gets a fresh free/discounted period — the same revenue-leak class the setup never-re-charge clause closed; PRD is silent, so Subscriptions would improvise. +- **Options**: (a) normative: a migrated subscription enters the target's **first non-trial phase** (a migration never grants a new `trial`; whether it may enter `intro` — sub-decision); carried on the `PlanMigrationScheduled` contract; (b) surface targets-with-trial-phases as an informational migration delta and leave the entry rule to a Product decision. +- **Recommendation**: (a) with "first non-trial phase" (i.e. `intro` allowed, `trial` never); mirror one clause into PRD fr-scheduled-migration. +- **Decision**: **(a) (autonomous), 2026-07-10** — a migrated subscription enters the target's **first non-trial phase** (a migration never grants a new `trial`; `intro` allowed); the rule rides the `PlanMigrationScheduled` contract. *Revenue-policy flavored — flagged for veto.* **Propagated**: S11 `inst-mg-boundary` + AC; PRD fr-scheduled-migration. + +#### D-40 [M] Trailing-tier qualification vs the two existing tier windows + +- **Where**: [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) `cpt-cf-bss-pricing-algo-trailing-tier`; [`design/03-price-structure.md`](./design/03-price-structure.md) Q2 (`tierAggregationWindow` resets the in-window counter); PRD §Advanced Pricing Primitives. +- **Problem**: a real PaaS-traffic pattern needs the **rate tier chosen from the *prior* month's total** while the current month is **billed hourly on actual usage** — the rate is known at period start and applied forward. `tierAggregationWindow` only expresses *current-window* accumulation (climb tiers within the period) and `billingGranularity` only sets billing cadence; neither expresses "prior-period qualifies, current-period billed", so the model can't represent it. +- **Options**: (a) overload `tierAggregationWindow` with a `trailing_month` value (conflates *counter reset* with *tier qualification*); (b) add a **separate** `tierQualificationWindow` (`current` \| `trailing_month`) as a Slice-10 advanced primitive, keeping the three windows orthogonal (qualification vs in-window aggregation vs billing granularity); (c) model it downstream in Tariffs only (no catalog field). +- **Recommendation**: (b) — the three concerns are genuinely distinct; overloading one enum (a) makes valid combinations unexpressible, and (c) hides an authored commercial choice from the catalog/snapshot. +- **Decision**: **(b) (autonomous), 2026-07-12** — add **`tierQualificationWindow`** (`current` default \| `trailing_month`) on tiered usage rows. `trailing_month` qualifies the rate tier from the **prior period's total** (single-band **volume**-style selection), **locks that one rate** for the current period into `pricingSnapshotRef` (the tier analogue of the FX rate-lock), and bills actual usage at `billingGranularity`; `current` preserves existing behaviour exactly. Forbidden on non-tiered/non-usage rows (`TIER_QUAL_ON_NON_TIERED`, 422); first-period **bootstrap** resolves to the lowest tier (or an authored bootstrap) and freezes. Ownership split follows the tier pattern: catalog **authors** the window and freezes it; **Rating** computes the trailing aggregate and re-qualifies at each period boundary; **Tariffs** applies the locked rate — the catalog never computes the aggregate. *Revenue-policy flavored — flagged for veto.* **Propagated**: S10 `cpt-cf-bss-pricing-algo-trailing-tier` + `inst-tt-*` + §5 `TIER_QUAL_ON_NON_TIERED` + DoD `cpt-cf-bss-pricing-dod-trailing-tier`; PRD §6.10 Trailing-tier qualification primitive row. + +#### D-41 [M] Phase-scoped entitlement grant set + +- **Where**: [`PRD.md`](./PRD.md) `cpt-cf-bss-pricing-fr-entitlement-grant-set` + §Entitlement grant set glossary; [`design/01-foundation.md`](./design/01-foundation.md) `ReadModel` (`phase→price map`). +- **Problem**: a time-boxed trial that should confer **smaller quotas than the paid phase** (e.g. 20 cloudlets / 1 IP during a 14-day trial, then unlimited / 16 IP in evergreen) has no representation: the model publishes **per-phase price** (`phase→price map`) but the **entitlement grant set is plan-level** (`PlanTier`-driven, one set per plan). There is no `phase→grant-set map`, so trial and evergreen cannot differ on limits — only on price. +- **Options**: (a) keep the grant set plan-level; model trial limits some other way (a separate trial *plan* with its own PlanTier, converted-to via plan change) — no per-phase entitlements, extra plan + migration; (b) add an optional **`phase→grant-set map`** to the published read model (mirroring `phase→price map`): a phased plan MAY author a grant set per phase; absent per-phase entries the plan-level `PlanTier` grant set applies; Subscriptions resolves the grant set for the **active phase at `t`**; (c) make `PlanTier` itself phase-resolvable (larger blast radius — PlanTier drives tax/GL/commercial tier, not just entitlements). +- **Recommendation**: (b) — smallest additive change that mirrors the already-accepted `phase→price` shape; keeps `PlanTier` plan-level (a) avoids the extra-plan/migration overhead and matches how operators think ("one trial offer, tighter caps early"). +- **Decision**: **(b) (autonomous), 2026-07-12** — the published read model MAY carry a **`phase→grant-set map`**: a phased plan authors an entitlement grant set (feature flags + quotas) **per phase**, published like `phase→price`; where a phase has no entry the **plan-level `PlanTier`-driven** grant set applies (backward-compatible). The catalog **publishes** the map and validates referential integrity (feature/quota/`PlanTier` defined in the registry — reuses the grant-set publish gate); **Subscriptions** resolves the grant set for the **active phase at `t`** and owns enforcement (this PRD never enforces). `PlanTier` stays plan-level (option (c) rejected). *Revenue/entitlement-policy flavored — flagged for veto.* **Propagated**: PRD `cpt-cf-bss-pricing-fr-entitlement-grant-set` + §Entitlement grant set glossary row; DESIGN `ReadModel` `phase→grant-set map`. + +#### D-42 [H] PriceOverlay — single adjustment → per-plan adjustment lines (reopens F-88) + +- **Where**: [`design/09-price-overlays.md`](./design/09-price-overlays.md) `inst-plv-adjustment` (one `adjustment_kind` + magnitude per list), §6 `pricing_price_overlay.adjustment_*`, §10 Risks ("adjustment-only overlay is the committed shape", F-88, 2026-07-04); PRD `cpt-cf-bss-pricing-fr-priceoverlay-authoring`. +- **Problem**: an overlay carries **one** adjustment (`markup|discount|fixed` + magnitude) applied across every plan its `target_ref` names — so a single negotiated segment deal that is **−20% on plan A, −15% on plan B, −10% on plan C** cannot live in one `PriceOverlay`. The committed answer is *N* separate same-class overlays (one per rate), a separate plan (different structure), or Contracts (per-account). But a `customerGroup` resolves such lists into one segment context, and authoring "one deal" as three sibling overlays with hand-managed precedence is the ergonomic gap operators hit in the studio. +- **Options**: (a) keep F-88 (single adjustment per list); differentiated per-plan pricing = multiple overlays (status quo — no model change); (b) make a `PriceOverlay` a **container of per-plan adjustment lines** — each line keyed `(planId, targetSku?)` with its own `adjustment_kind` + magnitude; **most-specific-wins within a list** (a `targetSku` line beats the whole-plan line for the priced row), **class rank still stacks across lists**; (c) full price-overlay-items matrix (per plan × currency × region × SKU) — the Zuora/SAP "price overlay = line items" model in full (largest surface). +- **Recommendation**: (b) — the smallest shape that closes the ergonomic gap while staying **adjustment-only** (it does *not* reopen "different tier structures", which stays Future / separate-plan per `inst-cg-routing`). It keeps the overlay-not-axis rule (L1) and the cross-class tie-break intact; it only refines precedence uniqueness (see consequences). +- **Consequences to resolve before adopting**: precedence uniqueness `UNIQUE (tenant_id, scope_class, precedence)` (L2) presumes one adjustment per list — with per-plan lines the collision domain narrows to **`(scope_class, planId, target)`**, or precedence is dropped *inside* a list in favour of the within-list most-specific rule (cross-list ordering stays class-rank). D-08 per-currency amount coverage (`pricing_price_overlay_amount`) and referential integrity (`inst-plv-referential`, D-31 dangling-on-retire) both **re-attach per line**. `inst-cg-routing` is **unchanged** (still: adjustment → overlay; structure → separate plan; per-account → Contracts). +- **Decision**: **PROPOSED 2026-07-13 — prototyped in Pricing Studio, flagged for veto.** The studio's `PriceOverlay` now holds `lines: [{ planId, targetSku?, adjKind, adjPct|fixedValue }]`; the price engine resolves the most-specific line per `(plan, sku)` and stacks lists by class rank; the editor is a per-`(plan × target)` line grid. This **reopens F-88's committed adjustment-only-*single*-magnitude shape** — a MAJOR launch-scope decision (2026-07-04) — so it does **not** land in the normative design until Product/Finance rule on it. *Commercial-model flavored — flagged for veto.* **Propagated (prototype only)**: Pricing Studio (`PRICE_LISTS[].lines`, `overlayLineFor`, per-plan line editor); this entry + veto markers on `inst-plv-adjustment` / §10 Risks and PRD fr-priceoverlay-authoring. + +#### D-43 [M] Prepaid grant — definitional parity with a Stripe credit grant (category, applicability, drawdown order) + +- **Where**: [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) `cpt-cf-bss-pricing-algo-prepaid-grant` + §5/§6/§8/§9; PRD `cpt-cf-bss-pricing-fr-prepaid-credit-grant`, §17.7 grant table, §17.4 "Prepaid grant" row, AC #90; source analysis: [STRIPE-GAP-ANALYSIS.md](./STRIPE-GAP-ANALYSIS.md) G-2. +- **Problem**: the grant carried only `grantAmount` / `creditUnit` / `expiryPolicy` / `autoRechargeAllowed` / `price`, so three **definitional** (authoring-time, not execution) capabilities were unrepresentable: (1) a **free promotional** grant — `price` was unconditionally required; (2) **spend scoping** ("this wallet applies to egress but not to compute") — structure authored at definition time, not balance state; (3) any stance on **drawdown order** when one account holds several grants (promo-before-paid is the canonical expectation). Stripe authors all three on the credit-grant object (`category`, `applicability_config`, `priority`). Rating-side check: the wallet grant is disjoint from step-6 `commitmentPools[]` (rating SEAMS M8), so nothing downstream models this either. +- **Options**: (a) keep the thin grant and let Billing add category/scoping when balance execution lands — Billing would end up owning *definitional* fields, outside the snapshot; (b) add the three as definitional catalog fields — `category` (`prepaid | promotional`), `applicability` (usage-line scope, **materialized at publish**), `drawdownPriority` (authored **default** only) — while the **effective** cross-grant order stays Billing-owned under a normative deterministic tie-break chain; (c) have the catalog also resolve execution order (violates "no balance state here"). +- **Recommendation**: (b) — additive, keeps the authoring/execution split exact (definition frozen in the snapshot; execution over account state stays Billing's), and mirrors the coupons principle: policy from the frozen snapshot, the executor never infers. +- **Decision**: **(b) (autonomous), 2026-07-14** — `category ∈ {prepaid (default), promotional}`: `promotional` is issued **free** (price rows MUST be absent — `GRANT_PROMO_PRICE_FORBIDDEN`; `autoRechargeAllowed` MUST be false — `GRANT_PROMO_AUTORECHARGE`; `expiryPolicy = never` warns — `GRANT_PROMO_NO_EXPIRY`). `applicability` = `all_usage` (default) or a set of **published** meters that are usage lines of the plan (never `one_time_setup` or recurring rows — launch rule); a metered `creditUnit` bounds the set to that unit's meters; publish **materializes** the resolved set into `pricingSnapshotRef` — the executor never infers scope. `drawdownPriority` (optional int, lower first) is an authored **default rank**; the **effective** order across an account's grants is **Billing-owned**, normatively `drawdownPriority` → `promotional` before `prepaid` → earlier expiry → earlier issuance → `grantId` (deterministic total order). Grant-price / `category` / `applicability` / `drawdownPriority` changes route through the material-change policy. Where drawdown sits relative to discounts and tax stays the Billing joint-contract open (STRIPE-GAP-ANALYSIS G-4). *Commercial-model flavored — flagged for veto.* **Propagated**: S10 `inst-pg-category` / `inst-pg-applicability` / `inst-pg-priority` (new) + `inst-pg-price` / `inst-pg-material` (amended) + §5 problem values + §6 `prepaid_grant` jsonb & `pricing_grant_price` iff-`prepaid` + DoD + §9 ACs + §10 risks; PRD glossary row, `fr-prepaid-credit-grant`, §17.4 "Prepaid grant" row, §17.7 grant table, AC #90, AC #26 aggregate fail-closed list; STRIPE-GAP-ANALYSIS G-2 marked actioned. + +#### D-44 [H] Level-based billing in launch — authorable `aggregationFunction {sum, peak, time_weighted}` (supersedes F-40) + +- **Where**: PRD §1.4 "Tier aggregation window" glossary row; PRD §15 answered-questions F-40 row; PRD §17.8 Follow-on row (→ Scope); [`DESIGN.md`](./DESIGN.md) §4 deferred list; [`design/03-price-structure.md`](./design/03-price-structure.md) Q2. Rating side: rating `T-D-17`, rating PRD §6.5 `fr-level-aggregation`, rating design/03 §4.3, design/12–13 (gauge intake + granule fold). +- **Problem**: F-40 was answered "not at launch" (2026-07-04) — usage `Q` window-**sum** only. The launch product set now confirmed to bill on **levels**: cloudlet **peak-per-hour** (Jelastic-heritage pay-per-use) and storage **GB-month** (time-weighted occupancy). Without an authorable aggregation the only bridge is source-side folding (source emits pre-folded counter deltas) — billable but wrong long-term: the commercial rule (peak? cadence?) lives in the emitter instead of the catalog, raw levels never reach audit, and retro re-aggregation is impossible. +- **Options**: (a) keep sum-only + source-side folding bridge for launch; (b) authorable `aggregationFunction ∈ {sum, peak, time_weighted}` with a **granule-fold** design — non-`sum` meters consume gauge samples, fold per `aggregationGranularity ∈ {hour (default), day}` granule (peak → max sample; time_weighted → step-integral level×dt), and window `Q` = **Σ granule folds**; (c) full Stripe-parity set incl. `last`/`unique`. +- **Recommendation**: (b) — the granule-fold keeps window-`Q` **additive by construction**, so every downstream invariant survives unchanged: the M7 counter key and single-writer, supersession continuity, T-D-12 `bandOffsetQ` slice math, package/volume band math, delta-only corrections (a late sample re-folds only its granule → `Q` delta → standard re-materialization). `last`/`unique` have no launch product and stay Future. +- **Decision**: **(b) (product call), 2026-07-16** — `aggregationFunction ∈ {sum (default), peak, time_weighted}` + `aggregationGranularity ∈ {hour (default), day}`, authorable on usage price rows, both frozen in `pricingSnapshotRef`. Non-`sum` rows: the meter is level-shaped (collector `gauge` kind); the sample unit is the level unit (GB, cloudlet), the billable `Q` unit is level·granule-hours (GB·h, cloudlet·h) — the SKU declares the **billable** unit (same doctrine as the composite output unit); publish validates sample-unit ↔ level-unit. Sampling-gap policy: `hold_last` bounded by a declared `maxHold`, beyond which the level reads 0 **and** an operator signal raises (fail-visible, never guessed) — `maxHold` value is Design-level. Non-`sum` MUST NOT co-occur with composite meters at launch (inputs stay window-sum). Joint Rating fixture required before publish of any non-`sum` row (AC #60-style). *Product-scope reversal — confirmed by the product owner 2026-07-16.* **Propagated**: PRD glossary/§15/§17.8 + DESIGN §4 + design/03 Q2 (this side); rating T-D-17 + PRD §6.5/§17.1 + design/03 §4.3 + design/12/13 (rating side); SEAMS M10 + UC6 updated. + +#### D-45 [M] First-class `includedAllowance` in launch — publish-compiled (supersedes F-32) + +- **Where**: PRD §1.4 (tier-row tail + new "Included allowance" glossary row), §6.10 `fr-included-allowance`, §15 F-32 row (re-decided), §17.8 row (→ Scope), AC #90a; [`DESIGN.md`](./DESIGN.md) §4 deferred list; [`design/03-price-structure.md`](./design/03-price-structure.md) Q6; [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) D-45-consumer note. +- **Problem**: F-32 (2026-07-04) kept allowances as the `$0`-first-band workaround. Existing-SKU migration (2026-07-16) needs "N included, then rate" as a first-class fact: the band covers the math but not display ("includes N units") or reporting (included vs billed), and `rolloverPolicy` — cross-period state — is inexpressible in a stateless band. The floated stopgap (netting the allowance at the usage source / CT facade) violates the D-44 doctrine: the commercial rule must live in the catalog, sources emit raw usage. +- **Options**: (a) keep $0-band + facade-only labeling (no rollover, no reporting split); (b) first-class authored `includedAllowance` with **publish compilation** — `none` → $0 band + frozen marker, `carry` → D-43 per-period promotional grant; (c) a new rating-side allowance evaluator (new step machinery). +- **Recommendation**: (b) — first-class authoring with **zero new evaluation machinery**: band math and grant drawdown both already exist and are fixtured; the compiler is publish-time, deterministic, and frozen into the snapshot. +- **Decision**: **(b) (product call), 2026-07-16, confirmed** — `includedAllowance = {quantity N > 0 (billable units), rolloverPolicy {none (default), carry(maxPeriods ≥ 1)}}` on `usage` rows; publish compiles per the Glossary/FR rules; publish fails on non-`usage`, double-free ($0 band + allowance), non-`sum` rows (level-meter allowance Future — variable level·granule boundary), `quantity ≤ 0`; per-seat scaling = named Future gate. Rollover **execution** stays Billing-owned (D-43 drawdown order); rating is untouched by construction. **Propagated**: PRD glossary ×2 / FR / F-32 / §17.8 / AC #90a; DESIGN §4; design/03 Q6; design/10 note. + +#### D-46 [M] Registry `sellable` flag + sellability-gate predicate 6 + +- **Where**: products gear PRD (`gears/bss/products/docs/PRD.md`): glossary "Sellable", `fr-sku-sellable`, mutability bucket iii, AC #2/#2a, provenance note; this PRD `fr-sellability-gate` (predicate 6 + component exemption); [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) sellability DoD. +- **Problem**: `published` means *referenceable*, not *offerable*. Migrated catalogs carry technical/component SKUs that must exist, meter, and compose without ever being sold standalone; the only gates were plan/market-level (five predicates, `not_sellable_ga`) — no SKU-level field. The need was parked as a "CF Design-phase decision"; post upstream-detach the registry PRD is ours, so the decision lands here. +- **Decision**: **(product call), 2026-07-16, confirmed** — SKU-level `sellable` (default `true`; `false` = composition/metering-only), material-but-mutable (bucket iii, governed change, frozen per `CatalogVersion`); enforced as sellability-gate predicate **(6)** for **standalone** lines; bundle-**component** references exempt (the component conjunction stays (1)–(5), (6) applies to the bundle SKU itself). **Propagated**: products PRD (glossary/FR/mutability/AC 2+2a/provenance); pricing `fr-sellability-gate` (five→six); design/07 DoD; rating SEAMS §I RG3 closed alongside (first-substantive-edit rule). + +## G. Ratifications — decisions already applied by the review fix wave + +Each item below was applied as an "obvious fix" but embeds a judgment call. **All confirmed +2026-07-10** (several extended by later D-decisions — noted per row); none reverted. + +| # | Applied decision | Where applied | Status | +|---|------------------|---------------|--------| +| R-01 | The **five sellability predicates** (S7's set) are now enumerated normatively in the PRD (fr-sellability-gate, §9.2) | PRD | **CONFIRMED 2026-07-10** | +| R-02 | Cutover emits **`PriceCreated` ×2** (copy + successor) + window events; both rows pass the Foundation pipeline and the commit **requests `CatalogVersion` addressability** like a supersession publish | PRD §17.5, S7 | **CONFIRMED 2026-07-10 (extended by D-03: windows gear-owned, the unit is one local transaction)** | +| R-03 | AC #87's discount-path GA flag is **plan-level** (every scope key of the grant-bearing plan), distinct from the per-market tax flag | PRD | **CONFIRMED 2026-07-10 (D-29 formalized the mechanics)** | +| R-04 | Custom-interval cap provisional values: `customEveryNDays ≤ 366`, `customEveryNMonths ≤ 24`, tenant-configurable (ratify the numbers before Design lock) | PRD nfr-size-limits / AC #84/#104 / §15 | **CONFIRMED 2026-07-10 (values still ride the §14 ratification)** | +| R-05 | **Bundle nesting forbidden at launch** — a component `planId` must not be a `bundle`-type plan (`COMPONENT_IS_BUNDLE`; nesting = Future) | PRD fr-bundle-composition, S8 | **CONFIRMED 2026-07-10** | +| R-06 | **Clone drops `existing_grandfathered` + superseded rows** (lifecycle state, not configuration) | PRD fr-plan-clone, S12 | **CONFIRMED 2026-07-10 (cohort-generalized by D-02: all generations dropped)** | +| R-07 | `migration_id` is **client-supplied** in the create POST (create-retry idempotency, mirroring S12 `run_id`) | S11 | **CONFIRMED 2026-07-10** | +| R-08 | Repricing journal: `pending \| applied \| failed` (+ one-transaction rule row+outbox+journal; run complete when no `pending`; `failed` retryable only via a new run) | S12 | **CONFIRMED 2026-07-10 (D-35 added pending-unit per-row failures)** | +| R-09 | Bulk import: **approval before Phase-2** (`awaiting_approval` state); bulk lock takes effect on entry to `committing`; response is **202 + GET report** | S12 | **CONFIRMED 2026-07-10 (D-37 added lease-resume + abort)** | +| R-10 | `pricing.migration.blocked_deltas` gauge → **`pricing.migration.blocked_total` counter** (blocked schedules never persist) | S11 | **CONFIRMED 2026-07-10** | +| R-11 | Foundation-owned problem types (`DUPLICATE_SCOPE_KEY`, `STALE_VERSION`, `IDEMPOTENCY_PAYLOAD_MISMATCH`, validation envelope with **advisory `warnings[]`**, 202 pending) — slices reference, never redefine | S1 §3.3, S3 | **CONFIRMED 2026-07-10** | +| R-12 | Append-only enforcement = trigger **column whitelist** (state-machine `lifecycle_state` flips + monotonic `grandfather_until` tightening); partial `UNIQUE` = one **current** row per key, temporal non-overlap enforced by S7 validation (not the index; window ownership consolidated into S7 by D-03/ADR-0003); `pricing_plan` mutable-in-place under the state machine; **history in-table** (no `pricing_price_history`) | S1, ADR, DESIGN | **CONFIRMED 2026-07-10 (key now eight columns — ADR-0002; phase axis id-typed — D-19)** | +| R-13 | **Pending approval is not a lifecycle state** (subject stays `draft`, mutation voids the record); the publish commit **re-runs the validation pipeline** inside the commit transaction (approval approves content, commit re-validates state) | S1 §3.6/§4.2, S5 | **CONFIRMED 2026-07-10 (D-10/D-13 route policy + imports through the same workflow)** | +| R-14 | `EligibilityExpirySignal` is **derived at read time** (`now ≥ grandfatherUntil`), never stored, no job/event; the backlog alarm reads Subscriptions re-bind feedback (mechanism = §10 joint contract) | S7 | **CONFIRMED 2026-07-10 (per-generation since D-02)** | diff --git a/gears/bss/pricing/docs/DESIGN.md b/gears/bss/pricing/docs/DESIGN.md new file mode 100644 index 000000000..aee95175d --- /dev/null +++ b/gears/bss/pricing/docs/DESIGN.md @@ -0,0 +1,510 @@ + + + +# Technical Design — Plan & Price Modeling + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional context](#4-additional-context) +- [5. Traceability](#5-traceability) + + + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-design-main` + +> **Canonical design entry point and index.** This document is Plan & Price Modeling's +> top-level technical design and the anchor for spec traceability. The design is authored +> as a **set of slice documents** under [`design/`](./design/) — a shared **Catalog +> Foundation** (the Plan/Price entity model, the canonical scope key, the publish engine, +> the fail-closed validation framework, the frozen read model + `pricingSnapshotRef` +> contract, and the event fan-out) plus per-capability slice designs. This page is the +> single index over that set — architecture overview, the phased slice map, dependency +> order, the cross-cutting normative statements, the ADR index, and the traceability +> surface — and delegates slice-level specifics (schemas, sequences, validation-rule +> internals) to the slice documents so they stay the single source of truth for their +> detail. + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +Plan & Price Modeling is the BSS Product Catalog's authoring surface and **System of +Record** for `Plan`, `Price`, bundle/add-on composition, and billing descriptors. It never +computes a charge, evaluates an overlay, or performs FX — it defines **what** the pricing +primitives MUST contain so that **Tariffs** can evaluate, **Subscriptions** can sell, and +**Rating** can charge deterministically and reproducibly from a frozen snapshot ([`PRD.md`](./PRD.md) §1.1). + +The design mirrors the sibling Billing Ledger's shape: a shared **Catalog Foundation** +([`design/01-foundation.md`](./design/01-foundation.md)) that owns the `Plan`/`Price` entity +model, the **canonical scope key**, the draft→publish state machine, the aggregate +**fail-closed validation pipeline** framework, append-only published-row history + +versioning/supersession, the **read-model projection** + `pricingSnapshotRef` stamping, and +the frozen event fan-out + `CatalogVersion`-increment request. Each business capability is a +**slice handler** that authors draft entity state, registers its validation rules and its +projected read-model fields, and **publishes *through* the Foundation** under the invariants +defined there. The Foundation owns no capability policy (it does not know what a billing +cycle is); slices own no publish mechanics (they never emit an event or stamp a snapshot +themselves). This keeps the correctness-critical publish/immutability/determinism core small +and auditable while letting each pricing capability evolve independently. + +Where the ledger's contract is *post through the engine* (build balanced lines → commit), +the catalog's contract is **publish through the engine**: author draft → run the fail-closed +validation pipeline → freeze a complete read model + `pricingSnapshotRef` → emit the frozen +event set → request a `CatalogVersion`. Consumers resolve **only** committed versions, never +draft state, and never substitute a default for an absent field (absence must have failed +publish). + +Requirements (WHAT/WHY) live in [`PRD.md`](./PRD.md); the "why this way" rationale for the +canonical scope key and the snapshot-versioning strategy is captured as ADRs in +[`ADR/`](./ADR/). + +### 1.2 Architecture Drivers + +Requirements from [`PRD.md`](./PRD.md) that significantly influence the architecture. + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-pricing-fr-publish-validation-failclosed` | The Foundation runs a single **aggregate fail-closed validation pipeline** at publish; slices register rules into it, and any invalid condition (§17.4) blocks `PlanPublished` and read-model warm — absence of a required field fails publish, never defaults downstream ([`design/01-foundation.md`](./design/01-foundation.md)). | +| `cpt-cf-bss-pricing-fr-published-rows-append-only` | Published `Price` rows are append-only history: `REVOKE UPDATE, DELETE` from the app role + `BEFORE UPDATE/DELETE` triggers; only never-published `draft` rows are deletable. Change is a new immutable row via versioning/supersession. | +| `cpt-cf-bss-pricing-fr-plan-versioning` / `cpt-cf-bss-pricing-fr-supersession` | Versioning creates a new immutable `Price` revision; supersession is versioning scoped to **one canonical scope key**, opening/closing a `PriceWindow` rather than overlapping it (§17.5). | +| `cpt-cf-bss-pricing-fr-pricing-snapshot` | Publish stamps the catalog-side identifiers sufficient for the manifest `pricingSnapshotRef` (resolved price ids + evaluation-policy version + the **pending** version ref, finalized to the committed `CatalogVersion` on `CatalogVersionPublished`, immutable thereafter); posted periods never re-query mutable rows. The catalog-side view MUST NOT diverge from the Tariffs composition SoR. | +| `cpt-cf-bss-pricing-fr-consumer-readmodel-resolution` | The read model is **monotonic per `CatalogVersion`**; consumers resolve `{skuId, planId, priceId}` + model kind + tier bands + evaluation-policy fields exactly as published, no draft read, no default substitution; a rating run pins one version. | +| `cpt-cf-bss-pricing-fr-catalogversion-increment` | On every `PlanPublished` the Foundation requests addressability; the registry (sole incrementer) MAY batch approved publishes; `PlanPublished` carries a **pending** ref and the snapshot pins the committed version on `CatalogVersionPublished` (§17.5). | +| `cpt-cf-bss-pricing-fr-publish-fanout-atomicity` | Post-commit read-model warming retries to the 5s SLO or emits `PlanPublishDegraded`; no state exposes a rateable-but-incomplete plan. | +| `cpt-cf-bss-pricing-fr-event-contract` | A **frozen event-name set** emitted with correlation/idempotency keys, ordered per `(tenantId, aggregateId)`, at-least-once, dedupable. | +| `cpt-cf-bss-pricing-fr-approval-two-person` / `cpt-cf-bss-pricing-fr-approval-threshold-policy` | A material change requires submitter + ≥1 independent approver (two distinct principals); fail-safe materiality (two-person rule unless an explicit threshold is configured and the change is below it and not a first publish). | +| `cpt-cf-bss-pricing-fr-model-kind` / `cpt-cf-bss-pricing-fr-tier-validation` | Explicit `modelKind` (no rating-time default) + `[fromQty, toQty)` tier bands validated ascending/non-overlapping/contiguous with an **always-open** top band (a closed top fails publish — capping is owned by entitlement quotas, D-17). | +| `cpt-cf-bss-pricing-fr-price-amount-validation` | Amount ≥ 0, valid ISO 4217, precision = the currency's ISO 4217 **minor unit** (no flat 2-decimal cap), no implicit FX (fail closed when a `(currency, region)` row is absent). | +| `cpt-cf-bss-pricing-fr-concurrent-edit` / `cpt-cf-bss-pricing-fr-mutation-idempotency` | Optimistic concurrency (ETag/version) rejects stale submits and bulk-vs-interactive collisions; client idempotency keys make create/update replays return the original. | + +#### NFR Allocation + +Non-functional requirements are specified in [`PRD.md`](./PRD.md) §7. Several load-bearing +targets are **provisional** and MUST be ratified before Design lock (no bare placeholders may +ship — [`PRD.md`](./PRD.md) §14); they are surfaced here with that status. + +| NFR theme | Allocated to | Design Response | Status | +|-----------|--------------|-----------------|--------| +| Publish → read-model propagation (p95 ≤ 5s) | Foundation publish engine + event fan-out | Batched `CatalogVersion` commit + retry-to-SLO warm or `PlanPublishDegraded`; pin never lags newest completed by > 5s | Committed target; batching-delay SLO **open with Registry** ([`PRD.md`](./PRD.md) §15) | +| Read / preview latency (p95 < 100ms per tenant partition) | Read-model projection store | Single indexed read of the projected, version-pinned read model; no evaluation on the read path | Committed target | +| Read-model availability / DR RPO/RTO | Read-model store + deployment topology | Fail-closed on read-model outage (never stale); DR numbers **provisional** | **Provisional — ratify before Design lock** | +| Determinism / reproducibility | Foundation snapshot + immutability | Append-only published rows, monotonic version, complete frozen `pricingSnapshotRef` | Committed | +| Audit retention (≥ 7 years, jurisdiction-configurable) | Governance/audit slice + Foundation audit store | Append-only, tamper-evident audit of every mutation + approval trail | Committed; retention-maximum vs minimum **open with Legal** ([`PRD.md`](./PRD.md) §15) | +| Mass-repricing worst-case throughput; plan/tier size caps; idempotency-key TTL | Foundation + operator-efficiency slice | Idempotent, deduplicated bulk events; per-row commit | **Provisional — ratify before Design lock** | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-pricing-adr-canonical-scope-key` | The single scope key for row-uniqueness, supersession, `PriceWindow` non-overlap, and coverage is `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` — the manifest's `(plan, currency, region, priceOverlay)` key extended **additively**, so a hybrid plan's components and a grandfathered row + its successor are **distinct keys** that can hold concurrent active windows without violating non-overlap. | +| `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` | Multi-generation grandfathering: the additive `cohort` axis (the cutover instant; `none` on non-grandfathered rows) makes every cutover a **new** coexisting generation; within the grandfathered class Tariffs selects the row by the cohort of the subscription's pinned price id (`pricingSnapshotRef`). | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` | The `PriceWindow` machinery (store, state machine, UTC activation job, `PriceWindow*` event production — frozen manifest names) is **owned by this gear** (Slice 7); the legacy effective-dating UC is absorbed as scenario source, and the cutover's multi-window unit is one local ACID transaction. | + +Additional ADRs are planned as the dependent slices land (snapshot/versioning strategy, +grandfathered-row immutability, customer-group ownership, derived-meter formula-as-data, +`CatalogVersion` increment/batching, `brand`-as-`PriceOverlay`) — see [§5 Traceability](#5-traceability) +and [`design/README.md`](./design/README.md). + +### 1.3 Architecture Layers + +```text +Capability slices plan-definition · price-structure · currency-tax · pricewindow-linkage · +(authoring policy) consumer-contracts · bundles · price-overlays · advanced-primitives · + governance · lifecycle · operator-efficiency + │ author draft state; register validation rules + read-model fields; publish through + ▼ the Foundation API — own no publish/immutability/snapshot mechanics +Catalog Plan/Price entity model · canonical scope key · draft→publish state machine · +Foundation fail-closed validation pipeline · append-only history + versioning/supersession · +(shared engine) read-model projection + pricingSnapshotRef · event fan-out + CatalogVersion request + │ — owns no capability policy + ▼ +Persistence toolkit-db backend (append-only published-row history; projected read model; + audit store; event outbox; ISO 4217 minor-unit money as integer minor units) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | REST authoring/publish/preview + read-model surfaces behind the inbound gateway; RFC 9457 problems; OAuth 2.0; ETag optimistic concurrency | Rust, REST/OpenAPI, inbound API gateway | +| Application | Capability slices author draft state and register rules/read-model fields; each is a bounded feature | Rust modules in the `pricing` gear | +| Domain | The Foundation publish engine, canonical scope key, validation pipeline, versioning/immutability, snapshot contract | Rust; GTS + Rust domain structs | +| Infrastructure | Append-only published-row history, projected read model, audit store, event outbox | PostgreSQL, SecureORM | + +#### Design set (ordered by implementation phase) + +The numeric prefix = **implementation order** (dependency-ordered phasing), **not** the PRD +§6 subsection number. As in the ledger, the two axes deliberately do not line up: a slice is +scoped by PRD decomposition but built when its dependencies exist. The full slice map, +dependency graph, and phase rationale live in [`design/README.md`](./design/README.md). + +| Doc | PRD §6 | Phase | What it is | +|-----|--------|-------|------------| +| [`design/01-foundation.md`](./design/01-foundation.md) | 6.2/6.7 core, §17.4/17.5 | 0/1 | **Foundation**: `Plan`/`Price` model, canonical scope key, draft→publish state machine, fail-closed validation pipeline, append-only history + versioning/supersession, read-model projection + `pricingSnapshotRef`, event fan-out + `CatalogVersion` request, tenant isolation, ISO 4217 money, idempotency/ETag. Carries the catalog-wide normative statements. | +| [`design/02-plan-definition.md`](./design/02-plan-definition.md) | 6.1, 6.3 | 1 | Billing cycles, custom frequency, per-seat quantity provenance (`quantitySource` persisted/validated in Slice 3), one-time-setup row, mandatory `PlanTier`, meter injectivity, add-on rules, phases + `convertsToPhaseId`, billing descriptors. | +| [`design/03-price-structure.md`](./design/03-price-structure.md) | 6.2 | 1 | Explicit `modelKind`, graduated/volume tier-band validation, `package` (block) pricing, evaluation-policy placement, joint golden-fixture conformance gate. | +| [`design/04-currency-tax.md`](./design/04-currency-tax.md) | 6.4 | 1/2 | Region/brand taxonomy validation, `taxInclusive`/`taxCategory` display basis + tax-display policy, single-currency-per-invoice binding. | +| [`design/05-governance.md`](./design/05-governance.md) | 6.7, 6.12 | 1/2 | Two-person rule + segregation of duties, per-currency threshold policy, RBAC deny-by-default, tenant/brand/region isolation, historical-import governance, audit completeness/retention. | +| [`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md) | 6.9 | 2 | Proration input contract, `billingTiming`, entitlement grant set, plan-change contract, rating compatibility, canonical `prorationBasis` enum. | +| [`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md) | 6.5 | 2 | `PriceWindow` ownership (store, state machine, activation job, `PriceWindow*` events — D-03), publish-time window coverage + future-gap checks, sellability gate, `priceEligibility`/`cohort`/`grandfatherUntil` + most-specific-wins resolution. | +| [`design/08-bundles.md`](./design/08-bundles.md) | 6.3 (bundle) | 2/3 | Bundle price basis, currency coverage, rev-share reconciliation, `invoiceItemization`. | +| [`design/09-price-overlays.md`](./design/09-price-overlays.md) | 6.6 | 3 | `PriceOverlay` authoring/validation + `customerGroup` segment pricing (BSS-owned taxonomy, effective-dated audited membership). | +| [`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md) | 6.10 | 3 | Reserved capacity (p1), prepaid credit grant (p2), derived (composite) meter formula-as-data (p2), `discountRef` hook, typed `minQtyThreshold`. | +| [`design/11-lifecycle.md`](./design/11-lifecycle.md) | 6.8 | 3/4 | Retirement, scheduled migration + `PlanLink`, idempotency/cancellation, contract-lock protection, legacy `migrated-origin` snapshot synthesis. | +| [`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md) | 6.11 | 4 | Clone, bulk import (all-or-nothing validate / per-row commit), mass repricing, price history + export. | + +#### Dependency order + +```text +01-foundation (scope key, publish engine, validation pipeline, read model + snapshot, events, immutability) + │ + ├─→ 02-plan-definition ─┬─→ 03-price-structure (Phase 1; a rateable plan needs both) + │ │ + ├─→ 04-currency-tax │ (Phase 1/2) + ├─→ 05-governance │ (gates every publish) (Phase 1/2) + │ ▼ + ├─→ 06-consumer-contracts (Phase 2; projects read-model fields onto 02/03 rows) + ├─→ 07-pricewindow-linkage (Phase 2; needs scope key + price rows) + ├─→ 08-bundles (Phase 2/3; references component planIds → needs 02–04) + ├─→ 09-price-overlays (Phase 3; overlays on published base rows) + ├─→ 10-advanced-primitives (Phase 3; reserved p1 may pull earlier) + ├─→ 11-lifecycle (Phase 3/4; needs windows + grandfathering + snapshot synthesis) + └─→ 12-operator-efficiency (Phase 4; clone/bulk/mass over the full authored surface) +``` + +- `02-plan-definition` + `03-price-structure` are co-required: the minimum rateable plan needs both a shape and a model kind/tier structure. +- `04-currency-tax` and `05-governance` gate the first *sellable* publish (currency/tax display + the approval gate). +- `06-consumer-contracts` and `07-pricewindow-linkage` form the downstream-determinism surface: the read-model fields consumers depend on and the coverage/sellability gate. +- `08-bundles` references published component `planId`s, so it follows 02–04. +- `09-price-overlays`, `10-advanced-primitives` layer overlays/primitives on published base rows. +- `11-lifecycle` needs windows (07) + grandfathering + `migrated-origin` snapshot synthesis. +- `12-operator-efficiency` operates over the whole authored surface, so it is last. + +## 2. Principles & Constraints + +The catalog-wide normative statements are authored in the Foundation design (§4); they are +surfaced here as design principles/constraints with stable ids. + +### 2.1 Design Principles + +#### Foundation owns publish; slices own capability policy + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-foundation-owns-publish` + +No slice emits an event, stamps a snapshot, or defines the scope key; the Foundation defines +no capability semantics (billing cycle, model kind, bundle). Slices author draft state, +register validation rules and read-model fields, and publish through the Foundation API. + +#### Publish through the engine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-publish-through-engine` + +Every state change reaches production one way: author draft → fail-closed validation pipeline +→ freeze read model + `pricingSnapshotRef` → emit the frozen event set → request a +`CatalogVersion`. There is no side door that mutates published state. + +#### Fail closed + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-fail-closed` + +Any invalid or ambiguous condition blocks publish and read-model warm; the absence of a +required field is a publish failure, never a downstream default. Consumers never read draft +state and never substitute defaults. + +#### Published state is append-only + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-published-append-only` + +Published `Price` rows are immutable history; change is a new immutable row via +versioning/supersession + `PriceWindow`. Only never-published `draft` rows are deletable. +Grandfathered rows are immutable in price — the only permitted mutation is tightening +`grandfatherUntil`. + +#### Determinism via frozen snapshot + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-frozen-snapshot` + +Consumers resolve a complete, frozen read model via `pricingSnapshotRef`, monotonic per +committed `CatalogVersion`; posted invoice periods never re-query mutable catalog rows. The +catalog-side snapshot view MUST NOT diverge from the Tariffs composition SoR. + +#### No charge computation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-no-charge-computation` + +The catalog persists and publishes structure only; it computes no monetary charge, evaluates +no overlay, and performs no FX. All mathematical formulas belong to Tariffs. + +### 2.2 Constraints + +#### Canonical scope key + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-canonical-scope-key` + +The single scope key for row-uniqueness, supersession, `PriceWindow` non-overlap, and window +coverage is `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)`. +Rows authored here always carry `priceOverlay = base`; defaults `phase =` the plan's terminal `phase_id` (id-typed axis; implicit terminal phase auto-created for non-phased plans — D-19), +`priceEligibility = all_subscriptions`, `cohort = none` (`cohort` ≠ `none` only on +`existing_grandfathered` generations — each cutover creates a new one). Normative: +[`design/01-foundation.md` §4](./design/01-foundation.md) · ADRs `cpt-cf-bss-pricing-adr-canonical-scope-key`, `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`. + +#### Money is ISO 4217 minor units + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-iso4217-minor-units` + +Amount precision follows the currency's ISO 4217 minor unit (0 for JPY/KRW, 2 default, 3 for +BHD/KWD/OMR); a flat 2-decimal cap MUST NOT be assumed. Amounts are `≥ 0` (negatives +rejected; typed credit rows are Future). No implicit FX — a missing `(currency, region)` row +fails closed. + +#### UTC time + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-utc-time` + +All effective dating, `PriceWindow` boundaries, `grandfatherUntil`, `availableFrom`/`availableTo`, +and anchor math are UTC. + +#### Tenant isolation; region decoupled from authz region + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-tenant-isolation` + +Plans, prices, and price overlays are tenant-scoped; SecureORM binds every query to the caller's +tenant. The pricing `region` axis is a **commercial territory** and is decoupled from the IdP +authorization-region claim; `region`/`brand` values MUST be members of the tenant's +configured taxonomies, validated before publish. + +#### AuthZ: PEP gate + resource/action catalog + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-authz-catalog` + +Every API surface enforces through the shared PEP `access_scope` gate with a +`(resource_type, action)` pair from the single normative catalog — GTS labels +`gts.cf.bss.pricing..v1~` (plan, bundle, price_overlay, customer_group, approval, +approval_policy, config, historical_import, audit), all outside `gts.cf.resources.*` so only +explicit catalog roles cover them; actions sit on real objects, never authz tiers. +Normative: [`design/05-governance.md`](./design/05-governance.md) §AuthZ Resource and Action +Catalog · gate constraint in [`design/01-foundation.md`](./design/01-foundation.md) §2.2. + +## 3. Technical Architecture + +The technical architecture is specified per slice in the [`design/`](./design/) set, with the +shared substrate in [`design/01-foundation.md`](./design/01-foundation.md). This section +summarises the cross-slice shape and declares the component/sequence ids; the phased slice +map and dependency order are in §1.3 and [`design/README.md`](./design/README.md). + +### 3.1 Domain Model + +Core entities live in the Foundation: `Plan` (binds a published SKU to a billing cycle, +mandatory `PlanTier`, optional phases, composition rules) and `Price` (a price row on the +canonical scope key with amount/currency, `modelKind`, tier bands, evaluation-policy fields, +and lifecycle metadata). Published rows are append-only, with immutable history rows preserved +on every versioning/supersession. A projected **read model** materialises the complete, +frozen per-`CatalogVersion` view consumers resolve via `pricingSnapshotRef`. Full field-level +definitions and the naming discipline are normative in +[`design/01-foundation.md`](./design/01-foundation.md) §4. + +### 3.2 Component Model + +Components are handlers over the shared Foundation, not independently deployable services. +Each carries a stable `cpt-cf-bss-pricing-component-{slug}` ID; phasing and dependency +order are in §1.3 and the linked slice doc is normative for its internals. + +#### Catalog Foundation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-foundation` + +Shared publish engine: `Plan`/`Price` model, canonical scope key, draft→publish state machine, +fail-closed validation pipeline, append-only history + versioning/supersession, read-model +projection + `pricingSnapshotRef`, event fan-out + `CatalogVersion` request ([`design/01-foundation.md`](./design/01-foundation.md)). + +#### Plan-definition handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-plan-definition` + +Billing cycles, custom frequency, per-seat quantity provenance (`quantitySource` in Slice 3), +one-time-setup row, mandatory `PlanTier`, meter injectivity, add-on rules, phases, billing +descriptors ([`design/02-plan-definition.md`](./design/02-plan-definition.md)). + +#### Price-structure handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-price-structure` + +Explicit `modelKind`, graduated/volume tier-band validation, `package` pricing, conformance +fixtures ([`design/03-price-structure.md`](./design/03-price-structure.md)). + +#### Currency-tax handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-currency-tax` + +Per-`(currency, region)` rows, region/brand taxonomies, tax-display basis + `not_sellable_ga` +gate, single-currency-per-invoice binding, base-price preview ([`design/04-currency-tax.md`](./design/04-currency-tax.md)). + +#### Governance handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-governance` + +Two-person rule, per-currency threshold policy, RBAC deny-by-default + the AuthZ +resource/action catalog, isolation, audit/retention +([`design/05-governance.md`](./design/05-governance.md)). + +#### Consumer-contracts handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-consumer-contracts` + +Proration input contract, `billingTiming`, entitlement grant set, plan-change contract, rating +compatibility ([`design/06-consumer-contracts.md`](./design/06-consumer-contracts.md)). + +#### PriceWindow-linkage handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-pricewindow-linkage` + +`PriceWindow` ownership (store/state machine/activation job — D-03), publish-time window coverage + future-gap, sellability gate, grandfathering resolution ([`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md)). + +#### Bundles handler + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-component-bundles` + +Bundle price basis, component currency/frequency coverage, rev-share reconciliation, +itemization ([`design/08-bundles.md`](./design/08-bundles.md)). + +#### Price-overlays handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-price-overlays` + +`PriceOverlay` authoring/validation + the customer-group taxonomy, effective-dated +audited membership, resolved-group freezing ([`design/09-price-overlays.md`](./design/09-price-overlays.md)). + +#### Advanced-primitives handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-advanced-primitives` + +Reserved capacity (same-row attributes), prepaid grant (GA-gated), derived meter +formula-as-data, `discountRef` hook, typed `minQtyThreshold` +([`design/10-advanced-primitives.md`](./design/10-advanced-primitives.md)). + +#### Lifecycle handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-lifecycle` + +Retirement, scheduled migration + `PlanLink`, contract-lock protection, `migrated-origin` +snapshot synthesis ([`design/11-lifecycle.md`](./design/11-lifecycle.md)). + +#### Operator-efficiency handler + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-component-operator-efficiency` + +Clone, two-phase bulk import, journaled mass repricing, history + export +([`design/12-operator-efficiency.md`](./design/12-operator-efficiency.md)). + +### 3.3 API Contracts + +The two primary contracts — the **authoring + publish** surface +(`cpt-cf-bss-pricing-interface-authoring-publish`) and the **published read model** +(`cpt-cf-bss-pricing-interface-catalog-read-model`) — are owned by the Foundation and +specified in [`design/01-foundation.md`](./design/01-foundation.md) §3.3. The base-price +**preview** (`cpt-cf-bss-pricing-interface-price-preview`) and the external integration +contracts (Tariffs read-model, Subscriptions publish, Registry `CatalogVersion`, Billing +descriptors, PriceWindow linkage — [`PRD.md`](./PRD.md) §9.2) are refined in the slices that +own their payloads. Concrete schemas, proto, and error taxonomies are owned by the slice +designs, not the PRD. + +### 3.4 Internal Dependencies + +- **`toolkit-db`** — transactional persistence for the append-only published-row history, the projected read model, the audit store, and the event outbox. +- **Coordination lease library** — singleton coordination for background work (read-model warming re-drive, window activation/expiration, mass-repricing runs, scheduled-migration dispatch). + +### 3.5 External Dependencies + +The catalog integrates with the BSS actors and systems defined in [`PRD.md`](./PRD.md) §3.2 / +§13. These are integration boundaries, not components owned here: + +- **Catalog registry (Product & SKU)** — SoR for `Product`/`SKU`/`Category`/`Attribute`/`CatalogVersion`, the `bundle` SKU type, `meteringUnit` declaration, and the `PlanTier` taxonomy; the **sole** `CatalogVersion` incrementer. The catalog consumes published SKUs and freezes content into the version. +- ~~PriceWindow (effective-dating use case)~~ — **consolidated into this gear** (D-03; PRD §15 answered): Slice 7 owns the window store, state machine, UTC activation job, and `PriceWindow*` event emission; the legacy UC document remains scenario source material. +- **Tariffs / PLAL** — consumes the read model and evaluates formulas/overlays/FX; composes the `pricingSnapshotRef` (composition SoR). +- **Subscriptions** — owns the plan-change boundary/mode + runtime, plan-change classification, trial runtime, entitlement enforcement, `PlanLink` migration, sellability checks (proration math = rating gear). +- **Rating** — consumes events + warmed read models; owns Usage → `RatedCharge` orchestration. +- **Billing / Payments** — consumes descriptors via `CatalogVersion`, derives deferral from `billingTiming`, owns refunds/credits and PSP/ERP posting. +- **Tax Engine** — scheme determination + `region` → jurisdiction mapping; **confirmed post-MVP**. MVP is tax-exclusive; `taxInclusive=true` plans are authorable but GA-gated. +- **Contracts** — contract locks + negotiated RI-style reservation rates. +- **Promotions** — coupon/discount authoring + evaluation (**PRD does not yet exist**); `discountRef` resolves to a registered external instrument. +- **Marketplace** — consumes bundle rev-share for fee accrual. + +### 3.6 Interactions & Sequences + +Per-flow sequences are specified in the corresponding slice documents; the load-bearing ones: + +#### Author → validate → publish → CatalogVersion + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-seq-author-publish` + +Draft authoring → fail-closed validation pipeline → approval (two-person rule for material +changes) → `PlanPublished` (pending version ref) → registry batches → `CatalogVersionPublished` +→ read-model warm to SLO (or `PlanPublishDegraded`); `pricingSnapshotRef` pins the committed +version ([`design/01-foundation.md`](./design/01-foundation.md)). + +#### Consumer read-model resolution + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-seq-readmodel-resolution` + +A consumer pins one committed `CatalogVersion` and resolves the complete frozen read model +via `pricingSnapshotRef` — no draft read, no default substitution, monotonic per version +([`design/01-foundation.md`](./design/01-foundation.md)). + +#### Grandfathering cutover + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-seq-grandfathering-cutover` + +One atomic approval unit shortens the current `all_subscriptions` window `effectiveTo` to the +cutover and schedules (a) an immutable `existing_grandfathered` copy — a **new `cohort` +generation**; prior generations stay untouched and concurrently live — and (b) the successor — +so no coverage gap opens and each grandfathered price stays live-resolvable yet immutable +([`design/07-pricewindow-linkage.md`](./design/07-pricewindow-linkage.md); §17.5). + +### 3.7 Database schemas & tables + +The canonical schema — `pricing_plan`, `pricing_price` (history = superseded rows retained in-table, keyed by `supersedes_price_id`), the scope-key unique +index, the projected `pricing_read_model`, the `pricing_catalog_version_ref` (pending/committed), the event +outbox, tenant policy objects, and the append-only audit store — is owned by the Foundation +and specified normatively in [`design/01-foundation.md`](./design/01-foundation.md) §4. +Slice-specific tables (phases, add-on rules, bundles, price overlays, customer-group membership, +migration schedules) are introduced by their respective slice documents. Money columns are +stored as integer minor units at the currency's ISO 4217 scale. + +### 3.8 Deployment Topology + +The catalog runs as a stateless authoring/publish + read-model service over a shared +`toolkit-db` backend; background work (read-model warm re-drive, mass repricing, scheduled +migration) is coordinated as a singleton via the coordination lease library. The read path is +served from the projected read model for the p95 < 100ms target and fails closed (never stale) +on read-model outage. Deployment specifics are platform-standard for a BSS gear. + +## 4. Additional context + +- **Design decisions register** — the 2026-07-09 review wave's decision register is [`DECISIONS.md`](./DECISIONS.md): **all 39 decisions + 14 ratifications closed 2026-07-10** and propagated into the slice docs + PRD (incl. ADR-0002 `cohort` axis, the window-machinery consolidation, and the closed-top ban); reopening an item = flip its status there and record why. +- **Telemetry** — publish throughput, validation-catch rate, publish→read-model propagation lag, degraded-publish count, and approval outcomes are surfaced per the governance/observability slice ([`design/05-governance.md`](./design/05-governance.md)). +- **Provisional NFRs (Design-lock blockers)** — read-model availability / DR RPO-RTO, mass-repricing worst-case throughput, plan/tier size caps, and idempotency-key TTL are working assumptions in [`PRD.md`](./PRD.md) §14 and MUST be ratified before Design lock; no bare placeholders ship. +- **Conformance fixtures before code** — the jointly-owned golden fixtures (tier-boundary, package, per_unit, proration, reserved, supersession-continuity) MUST be stood up and version-controlled **before** implementation; publish of any `modelKind` lacking a joint fixture is blocked ([`PRD.md`](./PRD.md) §13, AC #60/#61). +- **Open cross-team items** ([`PRD.md`](./PRD.md) §15) that materially shape the design: the `CatalogVersion` increment-trigger taxonomy + max batching-delay SLO value (Registry), the minimum Billing/ERP descriptor field set, the upstream SKU retirement/unpublish joint contract, and the cross-boundary (currency/region/frequency) cancel+new sign-off. +- **Deferred to Future scope** — typed credit/discount (negative-amount) rows, `currencyFallbackPolicy` (FX fallback), `includedAllowance` **extensions** (per-seat scaling; level-meter allowance — the core `includedAllowance {quantity, rolloverPolicy}` is **in launch since D-45, 2026-07-16**, publish-compiled to $0-band / D-43 grant), `aggregationFunction = last | unique` (the `{sum, peak, time_weighted}` set is **in launch since D-44, 2026-07-16** — level-based billing), two-dimensional (seats × usage) single-line pricing, structural freemium flag, per-row `refundable`/`creditPolicy`, self-service term/auto-renew metadata, per-group different-tier structures, plan-level minimum fee / cap per period (the rating-side `PeriodFloorCapObligation` boundary is reserved — deferred part is the catalog authoring field), and committed-usage / drawdown flags on plan (Contracts + Tariffs, Cross-PRD). The consolidated registry is [`PRD.md`](./PRD.md) §17.8. + +## 5. Traceability + +- **PRD**: [`PRD.md`](./PRD.md) +- **ADRs**: [`ADR/`](./ADR/) — `cpt-cf-bss-pricing-adr-canonical-scope-key`, `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`, `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (further ADRs planned as dependent slices land: snapshot/versioning strategy, customer-group ownership, `CatalogVersion` increment/batching, `brand`-as-`PriceOverlay`) +- **Design set**: [`design/`](./design/) — Foundation + per-capability slice designs; the phased map and dependency order are in §1.3 and [`design/README.md`](./design/README.md). diff --git a/gears/bss/pricing/docs/PRD.md b/gears/bss/pricing/docs/PRD.md new file mode 100644 index 000000000..01dca4929 --- /dev/null +++ b/gears/bss/pricing/docs/PRD.md @@ -0,0 +1,2623 @@ +--- +refs: + - bss/manifest/vz-arch-manifest-bss-only.md + - bss/prd/PRD-billing-ledger-balances-202604041200 + - bss/prd/PRD-contracts-agreements-202601120119 + - bss/prd/PRD-metering-pricing-module-202601120119 + - bss/prd/PRD-product-catalog-marketplace-202601120119 + - bss/prd/PRD-product-catalog-marketplace-202601120119/UC-effective-dating-price-windows-202601121200.md + - bss/prd/PRD-product-catalog-marketplace-202601120119/UC-plan-price-modeling-202601121200.md + - bss/prd/PRD-product-sku-management-202606101924 + - bss/prd/PRD-rating-engine-202604031200 + - bss/prd/PRD-subscriptions-entitlements-202601120119 + - bss/prd/PRD-tariffs-pricing-logic-202604011200 +--- + +# PRD — Plan & Price Modeling + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Architecture Alignment](#2-architecture-alignment) + - [2.1 Boundaries](#21-boundaries) + - [2.2 Canonical Scope Key (normative)](#22-canonical-scope-key-normative) + - [2.3 Predecessor Documents and Scope Migration](#23-predecessor-documents-and-scope-migration) +- [3. Actors](#3-actors) + - [3.1 Human Actors](#31-human-actors) + - [3.2 System Actors](#32-system-actors) +- [4. Operational Concept & Environment](#4-operational-concept--environment) + - [4.1 Module-Specific Environment Constraints](#41-module-specific-environment-constraints) +- [5. Scope](#5-scope) + - [5.1 In Scope](#51-in-scope) + - [5.2 Out of Scope](#52-out-of-scope) +- [6. Functional Requirements](#6-functional-requirements) + - [6.1 Plan Definition and Billing Cycles](#61-plan-definition-and-billing-cycles) + - [6.2 Price Structure and Model Kinds](#62-price-structure-and-model-kinds) + - [6.3 Plan Composition, Descriptors, and Phases](#63-plan-composition-descriptors-and-phases) + - [6.4 Multi-Currency, Regions, and Tax Display](#64-multi-currency-regions-and-tax-display) + - [6.5 PriceWindow Linkage and Coverage](#65-pricewindow-linkage-and-coverage) + - [6.6 Price Overlays and Segment (Customer-Group) Pricing](#66-price-overlays-and-segment-customer-group-pricing) + - [6.7 Publish Validation, Approval, and Events](#67-publish-validation-approval-and-events) + - [6.8 Plan Lifecycle: Versioning, Retirement, Migration](#68-plan-lifecycle-versioning-retirement-migration) + - [6.9 Consumer Contracts (Subscriptions / Rating / Billing)](#69-consumer-contracts-subscriptions--rating--billing) + - [6.10 Advanced Pricing Primitives](#610-advanced-pricing-primitives) + - [6.11 Operator Efficiency](#611-operator-efficiency) + - [6.12 Access Control and Governance](#612-access-control-and-governance) +- [7. Non-Functional Requirements](#7-non-functional-requirements) + - [7.1 NFR Inclusions](#71-nfr-inclusions) + - [7.2 NFR Exclusions](#72-nfr-exclusions) +- [8. Five Quality Vectors Analysis](#8-five-quality-vectors-analysis) +- [9. Public Library Interfaces](#9-public-library-interfaces) + - [9.1 Public API Surface](#91-public-api-surface) + - [9.2 External Integration Contracts](#92-external-integration-contracts) +- [10. Use Cases](#10-use-cases) +- [11. User Interaction and Design](#11-user-interaction-and-design) +- [12. Acceptance Criteria](#12-acceptance-criteria) + - [Plan definition and billing cycles](#plan-definition-and-billing-cycles) + - [Plan composition, phases, and descriptors](#plan-composition-phases-and-descriptors) + - [Pricing tiers and usage policy](#pricing-tiers-and-usage-policy) + - [Multi-currency, tax display, and price rows](#multi-currency-tax-display-and-price-rows) + - [Add-ons and bundles](#add-ons-and-bundles) + - [PriceWindow linkage](#pricewindow-linkage) + - [Publish validation, approval, and events](#publish-validation-approval-and-events) + - [Plan lifecycle, update, and migration](#plan-lifecycle-update-and-migration) + - [Operator efficiency](#operator-efficiency) + - [Price preview and access control](#price-preview-and-access-control) + - [Commercial model coverage](#commercial-model-coverage) + - [Proration and provisioning contracts](#proration-and-provisioning-contracts) + - [Currency and FX policy](#currency-and-fx-policy) + - [Lifecycle safety (contract lock and entitlement overflow)](#lifecycle-safety-contract-lock-and-entitlement-overflow) + - [Read-model and publish robustness](#read-model-and-publish-robustness) + - [Phases and PriceOverlay authoring](#phases-and-priceoverlay-authoring) + - [Migration robustness and eligibility](#migration-robustness-and-eligibility) + - [Cross-PRD contract conformance](#cross-prd-contract-conformance) + - [Approval, governance, and access control](#approval-governance-and-access-control) + - [Discounts and approval governance](#discounts-and-approval-governance) + - [Validation completeness](#validation-completeness) + - [Grandfathering and concurrency](#grandfathering-and-concurrency) + - [Scope-key and tax validation](#scope-key-and-tax-validation) + - [Lifecycle and structural integrity](#lifecycle-and-structural-integrity) + - [Currency binding and lifecycle integrity](#currency-binding-and-lifecycle-integrity) + - [Authoring guardrails and discount GA gate](#authoring-guardrails-and-discount-ga-gate) + - [Package and prepaid pricing](#package-and-prepaid-pricing) + - [Non-Functional Requirements (Show-Stoppers)](#non-functional-requirements-show-stoppers) + - [Commercial-shape and lifecycle completeness (review follow-ups)](#commercial-shape-and-lifecycle-completeness-review-follow-ups) + - [Governance and referential-integrity completeness (review follow-ups)](#governance-and-referential-integrity-completeness-review-follow-ups) +- [13. Dependencies](#13-dependencies) +- [14. Assumptions](#14-assumptions) +- [15. Open Questions](#15-open-questions) +- [16. Risks](#16-risks) +- [17. Reference Materials](#17-reference-materials) + - [17.1 Supported Billing Cycles and Price Structure Kinds (catalog)](#171-supported-billing-cycles-and-price-structure-kinds-catalog) + - [17.2 Model Kind / Tariffs Formula Mapping (conformance)](#172-model-kind--tariffs-formula-mapping-conformance) + - [17.3 Plan Composition Rules (normative)](#173-plan-composition-rules-normative) + - [17.4 Price Validation Rules (catalog)](#174-price-validation-rules-catalog) + - [17.5 Price-Change Mechanisms and CatalogVersion Increment Contract](#175-price-change-mechanisms-and-catalogversion-increment-contract) + - [17.6 Consumer Contracts Detail](#176-consumer-contracts-detail) + - [17.7 Advanced Pricing Primitives Detail](#177-advanced-pricing-primitives-detail) + - [17.8 Future Scope](#178-future-scope) + + + +## 1. Overview + +### 1.1 Purpose + +**Plan & Price Modeling** is the BSS Product Catalog capability that provides the authoritative way to define **subscription plans**, **price structures**, **add-ons**, **bundles**, and **billing descriptors** so that **Subscriptions** can sell, **Tariffs** can resolve commercial inputs, and **Rating** can charge **deterministically** and **reproducibly** from frozen snapshots. + +The Catalog remains **System of Record** for `Plan`, `Price`, bundle/add-on composition rules, and billing descriptors. This capability defines **what** those primitives MUST contain so **Tariffs** can evaluate and **Rating** can charge deterministically; it does **not** compute charges, evaluate overlays, or manage subscription runtime state (§5.2). + +> Where this PRD states a normative requirement, the **subject is the catalog (this PRD)** unless explicitly stated otherwise. + +### 1.2 Background / Problem Statement + +The Catalog is the SoR for the pricing primitives that downstream commercial systems consume. Without an explicit, publish-validated structure for plans and prices, rating inputs become ambiguous: overlapping tier bands, missing evaluation-policy fields, undeclared meters, and uncovered price windows reach production and produce non-reproducible charges, revenue leakage, and disputes. + +This PRD fixes the **catalog-side structure and publish contract** — billing cycles, tier bands, model kinds, `PlanTier`, evaluation-policy fields, billing descriptors, price windows, and the consumer read-model contract — so that a published plan exposes a complete, frozen read model for Tariffs and Rating and so that no ambiguous configuration can be published (fail-closed validation). It complements **Tariffs** (evaluation semantics), **Subscriptions** (runtime, proration math), and **Billing** (posting, invoice immutability). + +Industry practice for plan migrations favors **time-bounded grandfathering**, renewal-aligned cutovers, and advance notice (typically 60-90+ days for material increases). The catalog MUST support `priceEligibility` and scheduled migration paths so operators can implement these policies without cloning SKUs. + +### 1.3 Goals (Business Outcomes) + +- **Revenue accuracy**: zero ambiguous plan/price configuration reaching production; fail-closed publish validation. +- **Time-to-market**: self-service plan wizard, clone, and bulk price maintenance for Finance and Product teams (target >= 90% self-service for standard plan types). +- **Partner and marketplace enablement**: multi-currency, regional, and rev-share-aware bundles without SKU cloning. +- **Lifecycle safety**: retirement, grandfathering, and scheduled migration without mutating posted invoices. +- **Audit and compliance**: immutable price history, approval trails, and snapshot refs with **>= 7-year retention** (tenant/jurisdiction-configurable). +- **Engineering determinism**: published plans expose a complete read model (`{skuId, planId, priceId}`, model kind, tier bands, evaluation-policy fields, billing descriptors); plan mutations are versioned; active subscriptions continue on frozen snapshots until renewal or explicit migration; event fan-out enables cache warming within manifest SLOs. + +**Success metrics** + +| **Metric** | **Target** | **Measurement** | +|------------|------------|-----------------| +| Publish validation catch rate | 100% of known-invalid configs blocked before `PlanPublished` | QA + production validation audit | +| Pricing config incidents | Zero production charges from ambiguous meter or overlapping tier config | Incident tickets tagged `catalog-pricing` | +| Pre-GA config-validation coverage (leading indicator) | >= 99% of seeded-invalid configs caught in staging before GA | Staging validation-suite pass rate | +| Plan publish propagation | p95 <= 5s from `CatalogVersionPublished` (committed version) to Rating read-model visibility | Event timestamp delta | +| Plan read / preview latency | p95 < 100ms per tenant partition | APM on catalog read APIs | +| Manual IT involvement in plan creation | >= 90% self-service for standard plan types | Ops ticket volume vs plan publish count | +| Price history completeness | 100% of published price changes retain prior row | Audit sampling | +| Migration without invoice mutation | 100% of scheduled migrations use snapshot/`PlanLink` paths only | Billing reconciliation | + +### 1.4 Glossary + +| **Term** | **Definition** | +|----------|----------------| +| **Plan** | Catalog entity binding a published SKU to a **billing cycle** (one-time, recurring, usage-based, hybrid), add-on rules, optional `invoiceGroupingKey`, mandatory **PlanTier**, and optional phase price schedules. | +| **Plan composition** | Commercial elements on a plan: base and usage price row(s), tier bands, add-on rules, billing descriptors — not runtime subscription state. | +| **Price row** | Catalog `Price`: amount, currency, the **canonical scope key** `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` (**no `brand` axis** — brand pricing is a brand-scoped `PriceOverlay`), `minQtyThreshold`, rounding policy reference, `taxInclusive`, `billingTiming` (**required** on recurring rows), tier bands, evaluation-policy fields (`modelKind`, `tierAggregationWindow`, `billingGranularity` — usage rows only), and optional `grandfatherUntil` / `discountRef`. | +| **Pricing tier** | Quantity band `[fromQty, toQty)` with a unit price (graduated/volume); bands MUST be non-overlapping and ordered ascending by `fromQty`. Whole-block pricing is a separate `modelKind=package`, not a tier band. An **included allowance** (N free units bundled in a recurring fee) is authored **first-class** on the usage row (`includedAllowance`, D-45): `rollover = none` **compiles at publish** to the `$0` first band `[0, N)` plus a frozen first-class marker (display "includes N units"; reporting separates included from billed); `rollover = carry` compiles to the prepaid-grant machinery (D-43). See `fr-included-allowance` for constraints. | +| **Included allowance** | First-class `includedAllowance = { quantity N > 0 (in the row's billable units), rolloverPolicy ∈ {none (default), carry(maxPeriods ≥ 1)} }` on a `usage` price row (D-45, supersedes the F-32 $0-band-only stance). Publish-**compiled**: `none` → the `$0` first band + frozen allowance marker (evaluation = existing band math, no rating change); `carry` → a per-period **promotional grant** scoped to the row's meter (D-43 machinery; Billing executes drawdown; the catalog holds no balance). Launch constraints: `sum` rows only (level-meter allowance is Future — the boundary in level·granule units varies with period length), no per-seat scaling (Future decision gate), and a row MUST NOT combine an authored `$0` first band with `includedAllowance` (double-free; publish-blocked). | +| **Billing granularity** | Minimum billable unit for usage (`per_second`, `per_minute`, `per_hour`, `per_day`, whole-unit). MUST be set on usage price rows at publish. Tier-band quantities (`fromQty`/`toQty`) are expressed in **billable units after this quantization** (e.g. `per_hour` bands count hours, never raw seconds). | +| **Derived (composite) meter** | A catalog primitive binding a **formula over two or more published metering units** (e.g. `vCPU`, `RAM`) to a **single derived metering unit** priced as one line. The catalog persists the **constituent unit ids**, the **formula expression (as data)**, and the **output unit**; the constituent base units — and the **derived output unit itself** — are declared by the catalog registry (one meter namespace; this PRD binds the formula, it declares no meters). Tariffs evaluates the formula at rating time (math is **not** computed here); the composite definition is frozen in `pricingSnapshotRef`. | +| **Trailing-tier qualification** | Optional tiered-usage-row primitive `tierQualificationWindow` (`current` \| `trailing_month`), distinct from `tierAggregationWindow` and `billingGranularity`. `trailing_month` qualifies the **rate tier from the prior period's total** (single-band selection), **locks that rate** for the current period into `pricingSnapshotRef`, and bills actual usage at `billingGranularity` (canonical: PaaS egress — last month's volume sets `$/GiB`, billed hourly). Catalog authors the window; Rating supplies the trailing aggregate and re-qualifies; Tariffs applies the locked rate. | +| **Hybrid plan** | Plan combining **recurring base** price row(s) and **usage** price row(s) on the same `planId` for one SKU. | +| **Plan phase** | Named, ordered segment (e.g., trial, intro, evergreen) with its own price schedule reference and optional `convertsToPhaseId` successor. Catalog publishes the phase->price mapping, ordering, and each **non-terminal** phase's authored **`phaseDurationDays`** (> 0; REQUIRED at publish — `convertsToPhaseId` says *where* a phase converts, the duration says *when*; a terminal phase MUST NOT carry one). `displayTrialDays` is the `trial` phase's published `phaseDurationDays` (one value, PRD-named projection for preview/quoting). **Phase runtime mechanics** are owned by Subscriptions, which enforces from these published durations. Every phase MUST be covered by recurring rows per sold `(currency, region)`; **usage rows are phase-invariant by default** (an explicit phase-scoped usage row wins for its phase). | +| **Add-on rule** | Plan-scoped rule: eligible add-on SKU, required/optional, min/max/step quantity, optional price override reference. | +| **Bundle** | Composite of included SKUs with constraints, optional `revShare`, and `invoiceItemization` (`aggregate` \| `itemize`). For `sum_of_parts` pricing the bundle references the component **`planId`s** (not bare SKUs) so the summed rows are unambiguous per `(currency, region)` (and matching `frequency` for recurring components). | +| **PlanTier** | Manifest-required classification on SKU/Plan for Subscriptions, SLA, and quotas; distinct from **OrgTier** partner overlays. | +| **Billing descriptor set** | Per published SKU/Plan: invoice line template/labels, tax category, GL code, composition/itemization rules (manifest §4.1 contract). Plan-owned customer-facing content (plan/phase names, invoice line labels) is **single-language today**; per-locale **authoring** is owned by extending the registry's localization mechanism to Plan-owned fields, while **Presentation** owns invoice/preview rendering localization (decision — Open Questions). | +| **priceEligibility** | Who may receive a price/window: `all_subscriptions`, `new_subscriptions_only`, `existing_grandfathered`. When more than one class holds an active window on the same remaining axes, selection is **most-specific-wins**: `existing_grandfathered` > `new_subscriptions_only` > `all_subscriptions` (Tariffs step 2 applies this **after** eligibility matching). Within `existing_grandfathered`, the concrete **generation** is selected by the subscription's bound `cohort` — the cohort of its pinned price id (`pricingSnapshotRef`) — so the resolved row is always unique. The **successor** row in a grandfathering cutover carries `all_subscriptions`, so new subscriptions bind to it and a grandfathered subscription re-bound at `grandfatherUntil` expiry also resolves to it. | +| **pricingSnapshotRef** | Immutable composite reference (`CatalogVersion` + resolved price ids + evaluation-policy version) on charges and `BillableItem`. **Normative composition SoR**: Tariffs; this entry is the aligned catalog-side view and MUST NOT diverge from it. | +| **Plan migration** | Transition from retiring `planId` to target plan via Subscriptions `PlanLink` — not in-place posted invoice mutation. | +| **Grandfathering** | Carrying legacy pricing forward on a dedicated `existing_grandfathered` row for subscriptions activated before cutover (the row is immutable, so the retained price is necessarily a grandfathered **copy**); optionally **time-bound** via `grandfatherUntil` (null = indefinite). Grandfathering is **multi-generation**: each cutover creates a new `cohort` generation, and prior generations stay live and untouched. Because `priceEligibility` and `cohort` are part of the canonical scope key, every generation and the successor are **distinct keys** that hold active windows concurrently at the same `t` (no non-overlap violation); grandfathered subscriptions are **live-resolved** by Tariffs step 2 against an **immutable** row, so the price is stable, reconciling live resolution with the frozen-snapshot doctrine. | +| **Plan retirement** | Blocks **new** subscriptions; preserves snapshots for in-flight subscribers. | +| **Entitlement grant set** | The feature flags and quotas a Plan confers, published into the catalog read model so Subscriptions can provision and enforce them. Policy is `PlanTier`-driven (registry); enforcement is owned by Subscriptions. This PRD owns **publishing the grant set (or its `PlanTier`-resolved reference)** on the Plan, not entitlement semantics. **MAY be phase-scoped** (`phase→grant-set map`, D-41) so a trial phase confers tighter caps than evergreen; Subscriptions resolves it for the active phase at `t`. | +| **Proration input contract** | Read-model fields each recurring price row exposes so the downstream plan-change path is deterministic: `billingAnchorPolicy`, `prorationBasis`, and `creditOnDowngrade` (bool). Catalog publishes the inputs; Subscriptions owns the change boundary/mode, the rating gear the proration math (rating §6.11/§17.2). | +| **prorationBasis** | **Canonical** apportionment convention for a partial period — **one enum owned here and adopted verbatim by Tariffs**: `calendar_days_actual`, `calendar_days_30` (fixed 30-day month, day count capped at 30), `by_second`, `whole_unit` (no sub-period proration), or `none` (no proration — full-period charge, no partial credit). MUST be authored on the plan/price policy and **frozen in `pricingSnapshotRef`**; **all** mid-period proration uses it. Catalog names the convention; Subscriptions and Tariffs compute the amount from the same frozen value. | +| **billingAnchorPolicy** | When a recurring period anchors: `calendar_month` (1st), `subscription_start` (signup day), or `fixed_day(d)`. For `fixed_day(d)` — and for a `subscription_start` anchor under monthly-granular cycles incl. `customEveryN Months(n)` — a day exceeding the month length MUST clamp to the **last day of the month**, with the **anchor day preserved** across periods (each period clamps independently: Jan 31 → Feb 28 → Mar 31, no drift); all anchor math is **UTC**. | +| **billingTiming** | When a recurring fee is invoiced relative to its service period: `in_advance` (billed at period start) or `in_arrears` (billed at period end). **REQUIRED** on recurring price rows at publish; frozen in `pricingSnapshotRef`. Usage rows are implicitly `in_arrears`. Consumed by **Billing** (deferral policy) and Subscriptions; a hybrid plan MAY combine an `in_advance` base row with `in_arrears` usage rows. | +| **chargeKind** | Price-row axis distinguishing the charge components a plan carries at once: `recurring`, `usage`, `one_time` (a **one-time plan's base row**), or `one_time_setup` (a setup charge on a recurring/hybrid plan). Part of the canonical scope key, so a hybrid plan's components are **distinct** rows, not duplicates. | +| **one_time_setup** | A `chargeKind` for an optional one-time setup/activation charge on a recurring/hybrid plan; a first-class price row (not an add-on SKU). Charged **once per subscription lifetime**: at activation, or for a plan with a `trial` phase at entry into the **first non-trial phase** (a cancelled trial is never charged); a plan change or `PlanLink` migration **MUST NOT** re-charge the target plan's setup row. | +| **reservedRate / reservationFlavor** | Attributes on a `usage` price row for reserved-capacity pricing: `reservedRate` is the committed unit price for the reserved/allocated quantity; `reservationFlavor` is `consumption` (matched usage at the reserved rate, remainder on-demand) or `capacity` (allocated quantity billed regardless of usage). Aligned with Tariffs `reservationMatch`. | +| **allowedChangeTargets** | Plan-change-contract field: the target `planId`s a subscription MAY move to (**explicit list**; rule-based targets are Future scope); each edge carries its boundary classification (`in_place` \| `cancel_plus_new`); absence = **no self-service change** (fail-safe). Subscriptions enforces. | +| **comparabilityRank** | Plan-change-contract integer rank classifying a change as upgrade (higher), downgrade (lower), or switch (equal); drives proration sign/credit. `PlanTier` alone is **not** an ordering unless published as authoritative. | +| **availableFrom / availableTo** | Optional plan-level **purchasability dates** for **all billing cycles** (unified one pair). Validated against window coverage at publish; a purchase still requires an active window (sellability gate). | +| **PLAL** | Pricing Logic Abstraction Layer — the Tariffs evaluation surface resolving overlays, commitment, coupons, and FX. Catalog produces inputs; PLAL evaluates. | +| **SoR** | System of Record — authoritative owner of an entity's truth (e.g. Catalog registry is SoR for Product/SKU/`CatalogVersion`). | +| **SSP** | Standalone Selling Price — ASC 606 allocation reference; out of scope here (catalog MAY carry SSP tags in Future scope). | +| **OrgTier** | A partner's commercial standing/overlay axis (channel economics); distinct from `PlanTier` (the edition/grade of a SKU). OrgTier-scoped pricing is evaluated in Tariffs. | +| **invoiceGroupingKey** | Optional Plan field that groups billable lines onto the same invoice/section; consumed by Billing for line layout. Optional (empty = no grouping). It is a **layout hint only** and MUST NOT override the single-currency-per-invoice invariant: Billing MUST split lines of different currencies onto separate invoices even when their grouping keys match. | +| **Rounding policy reference** | A named rounding-policy id referenced by a price row; policy definition and application are owned downstream (Tariffs/Billing). Catalog persists the reference only. If unset on a row, publish MUST resolve the **tenant default rounding policy**; if neither exists, publish MUST fail (no implicit rounding). | +| **dimensionKey** | Identifier for a usage dimension on a `(meter, dimensionKey)` rated line; dimensional pricing by event properties is Future scope. | +| **region** | Catalog price-scope key (a **commercial/billing territory**, e.g. `US`, `EU`), part of the canonical scope key. It is **not** a tax jurisdiction (Tax Engine maps region -> tax jurisdiction) and is **independent of the authorization region** in IdP claims: a user's authz scope governs *which* rows they MAY mutate, while pricing `region` is the row's commercial scope. Region taxonomy is tenant-configured. | +| **brand** | A tenant sub-brand/label. **Not** a price-row axis (the manifest `Price` model has no `brand` field): brand-differentiated pricing is authored as a **brand-scoped `PriceOverlay`** overlay (`PriceOverlay.scope=brand`; Tariffs evaluates the overlay). A brand-scoped `PriceOverlay`'s `brand` MUST be a member of the tenant's configured **brand taxonomy** (validated at save); tenant-configured like `region`. `brand` MAY additionally be an authorization/display scope. | +| **customerGroup** | Operator-defined end-customer segment (trial/beta/VIP/on-discount/custom) for **segment pricing** via a `customerGroup`-scoped `PriceOverlay` (adjustment per group x region, resolved by `payerTenantId`). A **BSS-owned governed taxonomy**; membership is an **effective-dated, audited BSS record** on the payer's commercial profile (not an AMS/tenant-topology attribute). The resolved group is frozen in `pricingSnapshotRef`. | +| **Plan phase types** | `trial` = time-boxed evaluation phase (runtime owned by Subscriptions); `intro` = time-boxed introductory-price phase (typically after `trial`); `evergreen` = the **terminal** steady-state phase with no `convertsToPhaseId` successor. A phased plan MUST have exactly one terminal phase. | +| **Material change** | A plan/price change whose delta is **>= the configured approval threshold** (absolute or percent, per currency; for multi-currency changes, any row exceeding its own-currency threshold). If **no** threshold is configured, **all** changes are material (fail-safe). A **first publish** has **no prior baseline**, so a delta cannot be computed — it is **always material** (fail-safe). **Two-person rule** = the **submitter plus one independent approver** (two distinct principals); the submitter MUST NOT be the approver. A change **below** an explicitly configured threshold (and not a first publish) MAY auto-publish with no additional approver. | +| **grandfatherUntil** | Optional **UTC** bound on a grandfathered price row (per generation — each `cohort` carries its own). While null, grandfathering is **indefinite** across renewals; once passed, a bound subscription MUST re-bind to the current eligible row at its next renewal (catalog publishes the bound, Subscriptions executes). | +| **cohort** | Scope-key axis: the grandfathering **generation** discriminator — the UTC cutover instant that created the generation. `none` on every non-grandfathered row (publish-enforced: `cohort ≠ none` ⇔ `priceEligibility = existing_grandfathered`). Selection among generations is by the subscription's pinned price id, never by class specificity. Unrelated to `customerGroup` segment pricing (rationale: Design ADR set). | +| **minQtyThreshold** | A floor on a price row; **distinct** from tier `fromQty`. Its semantics depend on the billing cycle and MUST be one of two **explicitly-typed** floors: **(a) purchase-quantity floor** — enforced by **Subscriptions at order time**: a purchase below the floor is **rejected** (not silently zero); **(b) usage-quantity floor** — an **eligibility** floor evaluated **downstream** (Tariffs/Rating): usage below the floor is **not eligible** for the row's pricing and MUST **fail closed** to the row's declared fallback, **not** be silently zero-rated. The row MUST declare which floor type applies; a `usage` floor additionally declares its fallback (launch: `exception` only — the rating exception path). Catalog persists the type + value + fallback and freezes them in `pricingSnapshotRef`. Both MAY be set. | +| **discountRef** | Optional reference to an **external** discount instrument (owned by Promotions/Tariffs). The catalog validates **referential integrity only** — it does not author, evaluate, or stack discounts. Conditional day-1 hook. | + +## 2. Architecture Alignment + +| **Field** | **Value** | +|-----------|----------| +| **Applicable Manifest(s)** | BSS | +| **Relevant Chapters** | §4.1 Product and Service Catalog (Plan/Price, bundles, add-ons, billing descriptors, approvals, publish); §4.2 Rating and Charging (`pricingSnapshotRef`, stable catalog refs); §4.3 Subscriptions and Entitlements (`PlanLink`, composition); §2.1.3 Multi-tenant semantics (`PlanTier`, `{resourceTenantId, payerTenantId, sellerTenantId}`); §4.8 Marketplace (bundle rev-share where applicable) | + +> **Normative alignment**: Catalog remains **SoR** for `Plan`, `Price`, bundle/add-on composition rules, and billing descriptors. This PRD defines **what** those primitives MUST contain so **Tariffs** can evaluate and **Rating** can charge deterministically. It MUST NOT contradict manifest invariants: non-overlapping `PriceWindow` rows per the **canonical scope key** (§2.2), immutable posted financials, stable `{skuId, planId, priceId}` on billable artifacts, and `pricingSnapshotRef` on outputs. **Approval interpretation**: the manifest's multi-approver workflow is read as multi-**party** — a material change requires the submitter **plus >= 1 independent approver** (two-person rule; see Glossary Material change). + +### 2.1 Boundaries + +> **Boundary with Tariffs**: This PRD owns catalog-side **structure and publish contract** (billing cycles, tier bands, add-on rules, `PlanTier`, model kind, evaluation-policy fields). **Tariffs** owns **evaluation semantics** (graduated vs volume math, override hierarchy, coupons, FX steps). **Rating** owns Usage -> `RatedCharge` / `BillableItem` orchestration. + +> **Boundary with Price Windows**: **consolidated** (§15, answered 2026-07-10) — window scheduling, the UTC activation job, and `PriceWindowScheduled`/`Activated`/`Cancelled`/`Expired` event emission are **owned by this PRD's design** (the pricing gear; scheduler UI in the Frontend DESIGN). The legacy effective-dating use case is retained as scenario source material. Every billable `Price` row MUST be **linkable** to effective windows and **publish-validated** so Tariffs step 2 never resolves from draft-only state. + +> **Boundary with Catalog registry (Product & SKU)**: `Product`, `SKU` (including the `bundle` **type** flag), `Category`, `Attribute`, the `PlanTier` **taxonomy**, the metering-unit **declaration**, and the catalog-wide **`CatalogVersion`** are owned by the Product & SKU registry. This PRD **consumes published SKUs** and **freezes** its plan/price/descriptor content into the `CatalogVersion` that the registry publishes; it MUST NOT re-author Product/SKU/Category/Attribute or re-define `CatalogVersion`. This PRD owns **Plan**, **Price**, **PriceWindow** linkage, **PriceOverlay authoring**, **bundle composition**, **add-on rules**, and **billing descriptors**. + +### 2.2 Canonical Scope Key (normative) + +The single scope key for row-uniqueness, supersession, `PriceWindow` non-overlap, and window coverage is **`(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)`**. `chargeKind` in `recurring | usage | one_time | one_time_setup` distinguishes the price components a single plan legitimately carries at once; `cohort` (default `none`) is the grandfathering **generation** discriminator — set to the cutover instant on `existing_grandfathered` rows only, so repeated cutovers create coexisting generations — a hybrid plan holds a `recurring` **and** a `usage` row (optionally a `one_time_setup` row) on one `planId`, and a **one-time plan's base row is `one_time`**, so they are **distinct keys**, not duplicates. + +Axis values / defaults: `priceOverlay = base`; `phase =` the plan's **terminal `phase_id`** — the axis is always id-typed: a phased plan uses its authored terminal phase, a non-phased/one-time plan gets an implicit terminal phase (kind `evergreen`) auto-created at plan creation (setup rows ride the same id); `priceEligibility = all_subscriptions`; `chargeKind` per row; `cohort = none`. **Price rows authored here always carry `priceOverlay = base`** — partner/orgTier/brand overlays are separate `PriceOverlay` rows evaluated downstream by Tariffs; publish-time coverage resolves on the **base** key (overlays applied at evaluation). **`brand` is NOT a price-row axis**: brand-differentiated pricing is a **brand-scoped `PriceOverlay`** overlay, consistent with the manifest §4.1 invariant. The manifest §4.1 and §8.2 non-overlap invariants and the Tariffs non-overlap key are aligned to this key (extended additively with `phase`, `priceEligibility`, `chargeKind`); the effective-dating use case's narrower `(plan, currency, region, priceOverlay)` key is **superseded** by this canonical key for normative purposes. + +### 2.3 Predecessor Documents and Scope Migration + +| **Source** | **Relationship** | +|------------|------------------| +| Product Catalog & Marketplace PRD | Parent catalog PRD; Plan & Price Modeling was P0 scope with a use-case child | +| Product & SKU Management PRD | Catalog **registry** this PRD builds on (SoR for Product/SKU/Category/Attribute/`CatalogVersion`, the `bundle` SKU type, metering-unit declaration, and `PlanTier` taxonomy); upstream foundation, not superseded | +| Plan & Price Modeling use case | Legacy use-case format; **superseded** by this PRD for normative requirements. The partner **effective-price** preview (with pricing hierarchy) is **narrowed** here to base list price + overlay disclaimer; an accurate end-customer quote requires Tariffs overlay evaluation — Tariffs MUST pick up a partner-facing effective-price preview requirement (Open Questions) | +| Effective-dating price-windows use case | **Consolidated into this PRD** (windows owned by the pricing gear; the UC doc = scenario source) | +| Tariffs — Commercial Pricing Logic PRD | Downstream consumer; authoritative for formula evaluation and `pricingSnapshotRef` composition | +| Metering & Pricing Module PRD | Metering collection only; pricing hierarchy evaluation in Tariffs | + +## 3. Actors + +### 3.1 Human Actors + +#### Finance Manager + +**ID**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Role**: Defines plans, prices, tiers, tax-inclusive flags, and submits publishes for approval. +**Needs**: Accurate recurring/usage pricing; audit trail; safe price changes. + +#### Product Manager + +**ID**: `cpt-cf-bss-pricing-actor-product-manager` + +**Role**: Configures add-ons, bundles, and plan composition. +**Needs**: Package deals; compatibility rules; marketplace bundles. + +#### Finance Reviewer + +**ID**: `cpt-cf-bss-pricing-actor-finance-reviewer` + +**Role**: Approves material price/plan publishes under the two-person rule. +**Needs**: Two-person rule; rejection with reason; threshold controls. + +#### Partner / System Integrator + +**ID**: `cpt-cf-bss-pricing-actor-partner` + +**Role**: Previews list prices for end-customers. +**Needs**: Region/currency-scoped catalog base price before Tariffs overlays (requires an explicit catalog-preview read grant). + +#### Catalog Admin + +**ID**: `cpt-cf-bss-pricing-actor-catalog-admin` + +**Role**: Tenant-scoped catalog governance. +**Needs**: RBAC, bulk operations, retirement and migration orchestration; the scoped historical-import (backdating) capability where authorized. + +#### Auditor + +**ID**: `cpt-cf-bss-pricing-actor-auditor` + +**Role**: Reads price history and approval records. +**Needs**: Immutable history; export; no PII in events beyond policy. + +### 3.2 System Actors + +#### Catalog Registry (Product & SKU) + +**ID**: `cpt-cf-bss-pricing-actor-catalog-registry` + +**Role**: SoR for Product/SKU/Category/Attribute/`CatalogVersion`, the `bundle` SKU type, metering-unit declaration, and `PlanTier` taxonomy; the **sole** incrementer of `CatalogVersion`. + +#### Rating (incl. the evaluation core — former "Tariffs / PLAL") + +**ID**: `cpt-cf-bss-pricing-actor-rating` + +**Role**: The consolidated rating gear (rating ADR-0002 — the former Tariffs/PLAL consumer merged into this actor). Its **evaluation core** consumes the published read model (model kind, tiers, evaluation-policy fields, derived-meter definition, reserved rate, `customerGroup` overlays), evaluates commercial prices, and composes `pricingSnapshotRef`; its **pipeline** consumes events and warmed read models, resolves `{skuId, planId, priceId}` and evaluation-policy fields deterministically, and never reads draft state. + +#### Subscriptions + +**ID**: `cpt-cf-bss-pricing-actor-subscriptions` + +**Role**: Sells eligible plans; owns the plan-change boundary/mode (`changeEffectiveAt`, `changeMode`), plan-change classification, trial runtime, entitlement enforcement, `PlanLink` migration, and sellability checks from the published inputs (proration math is evaluated by the rating gear). + +#### Billing + +**ID**: `cpt-cf-bss-pricing-actor-billing` + +**Role**: Consumes billing descriptors via `CatalogVersion`; derives deferral policy from `billingTiming`; posts immutable invoices. + +#### Marketplace + +**ID**: `cpt-cf-bss-pricing-actor-marketplace` + +**Role**: Consumes bundle rev-share rules for fee accrual. + +#### Tax Engine + +**ID**: `cpt-cf-bss-pricing-actor-tax-engine` + +**Role**: Determines tax scheme (VAT/GST/US sales tax) and maps `region` -> tax jurisdiction; consumes `taxInclusive` display basis and `taxCategory` reference (no scheme/calc here). Post-MVP dependency. + +#### Promotions + +**ID**: `cpt-cf-bss-pricing-actor-promotions` + +**Role**: Owns coupon/discount authoring and evaluation; provides the external discount instrument that `discountRef` resolves against. + +#### Contracts + +**ID**: `cpt-cf-bss-pricing-actor-contracts` + +**Role**: Supplies contract locks and negotiated RI-style reservation rates (per-account overlay); contract-locked subscriptions are excluded from scheduled migration. + +## 4. Operational Concept & Environment + +### 4.1 Module-Specific Environment Constraints + +- **Multi-tenant isolation**: plans, prices, and price overlays are tenant-scoped; brand/region are scoped per IdP claims at the gateway. Pricing `region` is decoupled from the authorization region claim. +- **Time**: all effective dating, window boundaries, `grandfatherUntil`, `availableFrom`/`availableTo`, and anchor math are **UTC** (per manifest). +- **Determinism / snapshot**: published plans expose a complete, frozen read model; consumers resolve via `pricingSnapshotRef` and MUST NOT read draft state or substitute defaults for absent evaluation-policy fields. Posted invoice periods MUST NOT re-query mutable catalog rows. +- **No charge computation**: the catalog persists and publishes structure; it MUST NOT compute monetary charges, evaluate overlays, or perform FX. Mathematical formulas belong in Tariffs. +- **Precision**: amount precision follows the **ISO 4217 minor unit** for the row's currency (0 for JPY/KRW, 2 default, 3 for BHD/KWD/OMR); a flat 2-decimal cap MUST NOT be assumed. +- **Region / brand taxonomy**: `region` and brand-scoped `PriceOverlay` `brand` values MUST be members of the tenant's configured taxonomies, validated before publish. + +**Event alignment (manifest §4.1)** + +- **Producers (frozen event-name contract — names are normative; no aliases or renames without a versioned contract change)**: `PlanCreated`, `PlanUpdated`, `PlanPublished`, `PlanRetired`, `PlanMigrationScheduled`, `PlanPublishDegraded`, `BundleUpdated`, `PriceCreated`, `PriceUpdated`. Conditional emission governs *when* an event fires, not *whether* the name set is stable. +- **Manifest §4.1 alignment (additive)**: manifest §4.1 lists these producers additively alongside the pre-existing `PlanCreated/Updated`, `BundleUpdated`, `CatalogVersionPublished`, and `PriceWindowScheduled/Activated/Expired`. `CatalogVersionPublished` (registry) is **consumed** here, not re-declared; the `PriceWindow*` set is **produced by this PRD's design** since the window consolidation (frozen manifest names preserved). +- **Consumers (downstream)**: Rating (read-model warm), Subscriptions (eligibility + `PlanMigrationScheduled` -> `PlanLink` creation), Tariffs (policy warm), Billing (descriptor cache), Marketplace (bundle/listing). + +## 5. Scope + +### 5.1 In Scope + +> Priority mapping: HIGH -> `p1`, MEDIUM -> `p2`, LOW -> `p3`. Detailed requirements are carried as functional requirements (§6), acceptance criteria (§12), and normative appendices (§17). + +| **Feature** | **Priority** | **Notes** | +|-------------|--------------|-----------| +| Plan definition: one-time, recurring (monthly/quarterly/semiannual/annual/custom), usage-based | `p1` | Draft -> published; parent SKU published | +| Hybrid plans (recurring base + usage) | `p1` | Same `planId`; publish validation | +| Per-seat (per-unit recurring) plans | `p1` | `modelKind=per_unit`; `quantitySource=subscription_seat_count\|manual`; quantity resolved by Subscriptions | +| Custom billing frequency | `p2` | `quarterly`, `semiannual`, `customEveryN{Days\|Months}(n)`; interval `n` validated (> 0) | +| Plan composition and mandatory `PlanTier` | `p1` | Manifest §4.1; `PlanTier` taxonomy + SKU-level value owned by registry; enforced at plan publish here | +| Plan phase price schedules (catalog read model) | `p1` | trial/intro/evergreen price mapping + ordering + `convertsToPhaseId`; Subscriptions owns trial runtime | +| Proration input contract publication | `p1` | `billingAnchorPolicy`, `prorationBasis`, `creditOnDowngrade` on recurring rows; Subscriptions computes proration | +| Billing timing (`billingTiming`) on recurring rows | `p1` | `in_advance \| in_arrears`, required at publish, frozen; Billing deferral policy | +| Plan-change contract publication | `p1` | `allowedChangeTargets` + `comparabilityRank`; Subscriptions classifies upgrade/downgrade/switch | +| Plan sellability gate + `availableFrom`/`availableTo` (all cycles) | `p1` | Purchase blocked without an active window + committed version; unified purchasability dating; joint with Subscriptions | +| Optional one-time setup/activation charge | `p2` | First-class one-time row on recurring/hybrid plans, charged once per subscription lifetime (at activation; at trial conversion for trialed plans; never re-charged on migration) | +| Entitlement grant set in published read model | `p1` | Feature flags/quotas (or `PlanTier`-resolved ref); Subscriptions provisions/enforces | +| Base and regional price rows (multi-currency) | `p1` | ISO 4217; `region` scope; `brand` via brand-scoped `PriceOverlay` (not a price-row axis) | +| Price overlay authoring (`PriceOverlay`) | `p1` | Author/validate scope (partner/orgTier/brand/region/**customerGroup**/global), adjustment (markup/discount/fixed), explicit precedence. Precedence/stacking **evaluation** remains in Tariffs | +| Customer-group segment pricing (`customerGroup` `PriceOverlay` scope) | `p1` | Adjustment per group x region resolved via `payerTenantId`; BSS-owned group taxonomy + effective-dated audited membership; resolved group frozen in snapshot; membership changes audited; immediate re-resolution or bulk group moves = material change. Different per-group tier *structures* stay Future | +| Usage pricing tiers with explicit model kind | `p1` | graduated vs volume; non-overlapping bands | +| Package (block) pricing (`modelKind=package`) | `p2` | `packageSize`/`packagePrice`; round-up math in Tariffs | +| Prepaid credit grant (catalog definition) | `p2` | Grant fields frozen in snapshot; balance/drawdown owned by Billing/Rating (External dependency, GA gate) | +| Reserved-capacity price (self-service reserved rate + on-demand) | `p1` | `reservedRate`/`reservationFlavor` on the usage row; Tariffs evaluates (step 6) sourcing the rate from the snapshot | +| Interim `discountRef` day-1 discount hook | `p2` | Referential-integrity only; external discount instrument; evaluation in Tariffs/Promotions | +| `tierAggregationWindow` and `billingGranularity` on usage prices | `p1` | `billingGranularity` required on all usage rows; `tierAggregationWindow` on tiered usage rows; consumed by Tariffs | +| Tax-inclusive / tax-exclusive price flag per row | `p1` | Display and downstream tax basis; no tax calc here | +| Billing descriptor set per Plan/SKU | `p1` | Invoice template, tax category, GL, itemization | +| Add-on rules (dependency, bounds, overrides) | `p1` | Compatibility validation | +| Bundle definition (SKUs, constraints, rev-share, itemization, price basis) | `p2` | Marketplace; `sum_of_parts` vs `own_price` | +| `PriceWindow` ownership, linkage and publish-time coverage check | `p1` | Window store/state machine/activation + `PriceWindow*` events owned here (consolidated); linkage + validation here | +| `priceEligibility` / grandfathering on price rows (incl. `grandfatherUntil` bound) | `p1` | Tariffs step 2 consumes; renewal re-bind via Subscriptions | +| Catalog publish contract (events + read model) | `p1` | `PlanCreated/Updated/Published/Retired` | +| Consumer read-model resolution contract | `p1` | Tariffs/Rating deterministically resolve published inputs; no draft read, no default substitution | +| Publish fan-out atomicity / degraded handling | `p1` | Retry-to-SLO or `PlanPublishDegraded`; no charge against partially-published plan | +| Plan publish validation (fail-closed) | `p1` | Meters, tiers, descriptors, hybrid, PlanTier | +| Plan approval workflow (two-person rule) | `p1` | Thresholds; reject with reason | +| Tenant policy objects (approval threshold, tax-display) | `p1` | Fail-safe defaults: two-person rule if no threshold; fail-closed tax-display | +| Plan retirement and subscriber migration | `p1` | Grandfathering, scheduled cutover, alt plan | +| Migration idempotency & cancellation | `p2` | Re-trigger-safe; cancel-before-effective without affecting already-migrated subs | +| Legacy subscription snapshot synthesis | `p2` | Synthesize+freeze `pricingSnapshotRef` for subs lacking one (`migrated-origin`) | +| Plan versioning and immutable price history | `p1` | New price row on change; audit; see Price-change mechanisms (§17.5) | +| Contract-locked plan protection | `p1` | Reject structural mutation | +| Effective catalog price preview | `p2` | Base list price; Tariffs for full hierarchy. Draft-plan bill simulation needs a draft/sandbox extension (Open Questions) | +| Plan clone / duplicate | `p2` | Draft copy with new ids | +| Bulk price import/update | `p2` | All-or-nothing **validation**; **per-row commit** with conflict report | +| Mass repricing (idempotent, deduplicated events) | `p2` | Re-run-safe bulk adjustment with throughput SLO | +| Mutation idempotency keys | `p2` | Plan/Price create/update accept client idempotency key; duplicate returns original | +| Price history view and export | `p2` | Auditor/Finance | +| One-time min/max purchase quantity | `p2` | min/max qty (purchasability dating unified into the all-cycles `availableFrom`/`availableTo` row above) | +| Operator UX (wizard, tier editor, migration UI) | `p2` | Frontend DESIGN | +| RBAC: CatalogAdmin, FinanceReviewer, ProductManager, Auditor | `p1` | Tenant/brand/region scoped | +| Data residency for price/audit data per jurisdiction | `p2` | Aligns with jurisdiction-configurable audit retention NFR | + +### 5.2 Out of Scope + +- **Tariff evaluation formulas**, override hierarchy, coupon stacking, FX math — Tariffs. This includes **evaluating the derived (composite) meter formula**: this PRD persists the composite definition (units, formula-as-data, output unit); Tariffs computes the result. +- **`PriceWindow` scheduler UI** — the Frontend DESIGN (the window backend — store, activation job, events — is in scope here since the consolidation). +- **`PriceOverlay` precedence/stacking evaluation and contract override evaluation** — Tariffs + Contracts. (`PriceOverlay` **authoring/validation** is in Scope; see §5.1.) +- **Subscription state machine, plan-change boundary/mode + runtime, trial runtime mechanics, entitlement enforcement, plan-change-mid-cycle, downgrade entitlement-overflow enforcement, `PlanLink` execution** — Subscriptions; **proration math** — the rating gear (consumes `(changeEffectiveAt, changeMode)`). This PRD publishes the **inputs**; downstream executes. +- **Cross-currency / cross-region / cross-frequency mid-cycle plan changes** — NOT supported at launch (Future scope); handled as cancel + new subscription. +- **Coupon / discount / promo-code authoring** and coupon **evaluation/stacking** — Promotions domain / Tariffs. Neither coupon authoring nor evaluation is a catalog concern here. +- **Refunds, credit/debit notes, refund/credit eligibility execution** — Billing / Payments. Catalog does not flag per-row refundability (Future). +- **Prepaid credit balance ledger, drawdown, zero cut-off, auto-recharge execution** — Billing / Rating. Catalog only **defines** the grant (frozen in snapshot); balance is never tracked here. +- **PSP card rails, payment gateway integration, ERP/GL export** — Payments / Billing. Catalog supplies `glCode`/descriptors via `CatalogVersion` only. +- **Usage ingestion, `UsageRecord` shapes** — OSS / Metering. +- **Tax-scheme determination (VAT/GST/US sales tax), tax calculation, invoice posting, GL posting** — Tax Engine / Billing. Catalog owns only the tax **display basis** (`taxInclusive`) and `taxCategory` reference. +- **API schemas, proto, error codes** — Design doc(s). +- **Full ASC 606 recognition** — Finance/Billing; catalog MAY carry SSP reference tags in Future scope. +- **Self-service term / auto-renew metadata** (`termLength`/`autoRenew` on the Plan) — NOT a catalog concern at MVP; Billing's Catalog-term default is deferred with it (Future scope). + +## 6. Functional Requirements + +> **Content boundary**: FRs define WHAT the catalog MUST persist, validate, and publish, not data models or APIs. Concrete schemas, proto definitions, error taxonomies, and mathematical formulas are owned by the corresponding DESIGN. Heavy normative reference tables (billing-cycle requirements, price-structure kinds, model-kind conformance, plan-composition rules, price-validation rules, price-change mechanisms, consumer contracts, and advanced primitives) are preserved verbatim in §17. + +### 6.1 Plan Definition and Billing Cycles + +#### Supported billing cycles + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-billing-cycles` + +The catalog **MUST** support one-time, recurring, usage-based, and hybrid billing cycles with the per-cycle catalog requirements in §17.1. A Plan **MUST** reference a **published** SKU and be storable in `draft` before publish. Recurring frequency **MUST** support `monthly`, `quarterly`, `semiannual`, `annual`, and `customEveryN{Days|Months}(n)`. + +**Rationale**: The full commercial-cycle matrix is the foundation for IaaS/PaaS/SaaS and marketplace selling. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Custom billing frequency + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-custom-frequency` + +For a recurring Plan the catalog **MUST** persist the interval as metadata for `quarterly`, `semiannual`, or `customEveryN{Days|Months}(n)` and **MUST** reject a non-positive `n`. A `customEveryN Days(n)` cycle **MUST** anchor on `subscription_start` (a `calendar_month`/`fixed_day` anchor **MUST** fail publish); a `customEveryN Months(n)` cycle **MAY** anchor on `subscription_start` or `calendar_month` — a `subscription_start` day beyond the target month's length clamps to its **last day** (the same rule as `fixed_day`), with the **anchor day preserved** across periods (each period clamps independently: Jan 31 → Feb 28 → Mar 31, no drift; all UTC; joint anchor fixture with Subscriptions). `n` **MUST NOT** exceed the configured custom-interval cap. + +**Rationale**: Custom cycles must be explicit and anchor-compatible to rate deterministically. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Hybrid plan completeness + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-hybrid-completeness` + +A hybrid Plan **MUST** declare both a recurring base price row and a usage price row on the same `planId`; publish validation **MUST** reject a hybrid missing either part, and the read model **MUST** expose both rows under one `planId` for Tariffs hybrid evaluation. + +**Rationale**: Hybrid economics require both components present and co-resolvable under one plan identity. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Per-seat (per-unit recurring) pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-per-seat` + +A `modelKind=per_unit` row **MUST** persist a unit price and a `quantitySource` (`subscription_seat_count` | `manual`; and, for `manual`, the fixed quantity). The catalog **MUST NOT** infer, meter, or compute the quantity (zero metering-unit footprint); the read model **MUST** let Rating/Tariffs resolve the per-period quantity from the declared source. + +**Rationale**: Per-seat pricing must be explicit about quantity provenance without metering it. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Optional one-time setup/activation charge + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-one-time-setup` + +Recurring and hybrid plans **MAY** declare an optional one-time setup/activation price row (`chargeKind=one_time_setup`) on the same `planId`, charged **once per subscription lifetime** (at activation; for a plan with a `trial` phase — at entry into the first non-trial phase; never re-charged on plan change or `PlanLink` migration) and frozen in `pricingSnapshotRef`. It **MUST** be a first-class plan price row (participating in approvals, snapshot, preview) — **not** a synthetic add-on SKU — and publish **MUST** validate it as one-time (no recurrence, no `billingTiming`/tier fields). + +**Rationale**: Setup fees are common and must be first-class, not modeled as fake add-ons. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +### 6.2 Price Structure and Model Kinds + +#### Explicit model kind + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-model-kind` + +Each price row **MUST** persist an explicit `modelKind` (`flat` | `per_unit` | `graduated` | `volume` | `package`) with no implicit default at rating time; a tiered row with unspecified model kind **MUST NOT** publish. The catalog **MUST NOT** compute charges — Tariffs applies the formula per the conformance mapping (§17.2). `graduated`/`volume`/`package` are valid **only on usage rows** (`chargeKind = usage`) — a tiered/package kind on a non-usage row MUST fail publish; tiered per-seat pricing is Future scope. + +**Rationale**: Rating divergence is prevented only when the model kind is explicit and frozen. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Tier band validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-tier-validation` + +Under the `[fromQty, toQty)` convention, tier bands **MUST** be ascending by `fromQty`, non-overlapping, and contiguous (no gaps); the top band **MUST** be **open-ended** (`toQty=null`) — a closed top band **MUST** fail publish. "Price undefined above X" is never the commercial intent: quantity capping is owned by entitlement **quotas** (Subscriptions enforces), per-period fee caps are Tariffs Future scope, and a different price above X is simply another band — so any quantity is always rateable on a tiered row. + +**Rationale**: Off-by-one, overlap, and gap errors at band edges cause silent mispricing. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Package (block) pricing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-package-pricing` + +A `modelKind=package` row **MUST** persist `packageSize` (> 0) and `packagePrice` (>= 0) with tier-band fields **absent**; publish **MUST** reject otherwise. The read model **MUST** expose `packageSize`/`packagePrice`; Tariffs computes the round-up math (§17.2) and the catalog **MUST NOT** compute the charge. + +**Rationale**: Block pricing is a distinct model that must be authorable and gated on a joint fixture. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Model kind / Tariffs formula conformance + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-model-kind-conformance` + +The catalog `modelKind` enum and the Tariffs formula matrix **MUST** reconcile one-to-one per §17.2; publish of any `modelKind` lacking a **joint golden conformance fixture** **MUST** be blocked. `package` (repeating-block) and `per_unit` (external-quantity) **MUST** each have a joint golden fixture before publish. + +**Rationale**: A single kind-to-formula source of truth prevents catalog/Tariffs divergence. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Price amount and currency validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-price-amount-validation` + +A price amount **MUST** be >= 0 (`0` valid for free tiers, `trial`/`intro` phases, and the first graduated band; negatives rejected), currency **MUST** be valid ISO 4217, and precision **MUST** follow the currency's ISO 4217 minor unit. The catalog **MUST NOT** perform FX: if no row exists for a requested `(currency, region)`, preview/publish **MUST** fail closed (no base-currency fallback unless an explicit `currencyFallbackPolicy` is configured — Future). + +**Rationale**: Financial correctness requires non-negative, correctly-scaled, FX-free catalog amounts. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +### 6.3 Plan Composition, Descriptors, and Phases + +#### Mandatory PlanTier + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-plantier-mandatory` + +Every Plan **MUST** declare `PlanTier` before publish (optional at draft); the taxonomy and SKU-level value are owned by the registry. Publish **MUST** validate that the Plan's `PlanTier` **equals its parent SKU's `PlanTier`** unless an explicit, audited override is declared (no silent divergence), so downstream reads one unambiguous effective tier. A **post-publish** registry change of the SKU's tier **MUST** flag every affected published plan as tier-divergent in the read model (remediated by re-publish or an audited override); consumers keep resolving the frozen published tier — divergence is a remediation signal, never a silent retro-change. + +**Rationale**: Subscriptions/SLA/quotas depend on one unambiguous, present tier. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Meter injectivity + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-meter-injective` + +Each usage plan **revision** **MUST** map exactly one `meteringUnit` (per-row injectivity: one priced line per `(meter, dimensionKey)`); ambiguous mapping **MUST** fail publish. Multi-meter offerings **MUST** be modeled either as a **derived (composite) meter** (one output unit) or as separate single-meter SKUs composed via bundle/add-ons. + +**Rationale**: Injective meter mapping prevents line collisions and ambiguous rating. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Add-on rules + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-addon-rules` + +Add-on SKUs **MUST** be published and compatible with the base SKU; dependency/conflict edges are **plan-authored** on the add-on rules (referencing other add-ons of the same plan's set), and conflicting add-on pairs and dependency **cycles** **MUST** fail publish. A required add-on **MUST** have `maxQty >= 1`, and an optional price override reference **MUST** be persisted on the plan snapshot. The override reference **MUST** resolve to a **published price row of the add-on SKU's own plan** (a normal row with its own scope key, windows, and coverage); base-plan publish **MUST** validate the referenced row exists, is published, and covers every `(currency, region)` the base plan sells — a detached override amount on the base plan is not authorable. + +**Rationale**: Constrained, acyclic add-on composition keeps subscription assembly valid. + +**Actors**: `cpt-cf-bss-pricing-actor-product-manager` + +#### Bundle composition + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-bundle-composition` + +A Bundle **MUST** declare its price basis (`sum_of_parts` or `own_price`), reference **published** `includedSkuIds`, and (for `sum_of_parts`) reference component **`planId`s** whose rows cover **every** `(currency, region)` the bundle sells in (matching `frequency` for recurring components) — a missing/ambiguous component row **MUST** fail publish. A component `planId` **MUST NOT** itself be a `bundle`-type plan (flat composition at launch; bundle nesting is Future scope). Rev-share **MUST** sum to 100% per included vendor SKU with an explicit platform cut and a nominated primary party (default = the platform) absorbing rounding residual within <= 0.01% (1 bp) tolerance — publish normalizes the absorber's effective share to an exact split (typed values audited); `invoiceItemization` (`aggregate`|`itemize`) **MUST** be persisted and preserve per-SKU rev-share for Marketplace accrual. + +**Rationale**: Marketplace bundles must be unambiguous, currency-covered, and rev-share-reconciled. + +**Actors**: `cpt-cf-bss-pricing-actor-marketplace` + +#### Billing descriptor completeness + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-billing-descriptors` + +Publish **MUST NOT** proceed without the complete billing descriptor set per manifest §4.1 (invoice line template, tax category, GL code, composition/itemization rules); the validation report **MUST** list any missing descriptor fields. The set **MUST** be sufficient for Billing/ERP to post without re-querying mutable catalog rows. + +**Rationale**: Invoice reproducibility requires a complete, frozen descriptor set at publish. + +**Actors**: `cpt-cf-bss-pricing-actor-billing` + +#### Plan phase price schedule + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-plan-phases` + +For a phased plan the read model **MUST** map each phase id to its price row references, persist phase ordering and each phase's `convertsToPhaseId` successor, reject a dangling or cyclic `convertsToPhaseId`, and require **exactly one terminal phase**. Every phase **MUST** be covered by ≥ 1 published **recurring** price row for every `(currency, region)` the plan sells — an uncovered phase fails publish (a phase conversion must never resolve to nothing). **Usage rows are phase-invariant by default**: one usage row covers all phases, and an explicit phase-scoped usage row overrides it for its phase (phase-specific wins — a published resolution rule adopted verbatim by Tariffs, joint fixture; e.g. free trial usage = an explicit trial-phase usage row at 0). Every **non-terminal** phase **MUST** publish `phaseDurationDays` (> 0; publish fails on a non-terminal phase without a duration or a terminal phase with one); for a `trial` phase the catalog **MUST** publish `displayTrialDays` (= its `phaseDurationDays`) for preview/quoting. Subscriptions enforces phase runtime from these same published values (single source). + +**Rationale**: Phase->price resolution and trial length must be catalog-authored and acyclic; runtime stays in Subscriptions. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +### 6.4 Multi-Currency, Regions, and Tax Display + +#### Multi-currency price rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-multi-currency-rows` + +A Plan **MUST** support independent price rows per `(currency, region)` linked to the same `planId`, emitting `PriceCreated` per row. The system **MUST** support **at least 20 currencies per plan** as a guaranteed floor and hold the read SLO at that floor. + +**Rationale**: Global selling requires many first-class per-market rows without FX derivation. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Region and brand taxonomy validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-region-brand-taxonomy` + +A price row's `region` **MUST** be a member of the tenant's configured region taxonomy; an unknown region **MUST** fail validation before publish. `brand` is **not** a price-row field: a **brand-scoped `PriceOverlay`**'s `brand` **MUST** be a member of the tenant's configured brand taxonomy or fail validation. + +**Rationale**: Uncontrolled region/brand values corrupt scope resolution and overlay matching. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### Tax display basis + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-tax-display-basis` + +The catalog **MUST** persist the `taxInclusive` display basis and `taxCategory` reference only (no scheme/calc). A `taxInclusive=true` row without region tax readiness, and a `taxInclusive=false` row in a region with no configured `taxCategory`, **MUST** be governed by the tenant tax-display policy (default **fail-closed**). Region tax readiness (`taxCategory` + rate-present marker) is **tenant-declared per `(tenant, region)` configuration at MVP** and is verified against Tax Engine once it GAs (a divergence flags affected rows for re-publish, never a silent retro-change). Because Tax Engine is post-MVP, a `taxInclusive=true` **price row MAY** be authored but **MUST** be flagged **not-sellable-GA** until Tax Engine GA — the flag applies **per price row / `(currency, region)` market**, gating only the tax-inclusive markets of a mixed plan, not the plan as a whole; MVP sells tax-exclusive. + +**Rationale**: Display basis is a catalog concern; tax scheme/calc and jurisdiction mapping are gated on Tax Engine. + +**Actors**: `cpt-cf-bss-pricing-actor-tax-engine` + +#### Single-currency-per-invoice binding + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-invoice-currency-binding` + +Publish/preview **MUST** reject the enumerated configurations that would force mixed-currency lines onto a single invoice: (i) an add-on/override/required-add-on row lacking a row in a currency the base plan publishes; (ii) a `sum_of_parts` bundle whose component rows do not cover every currency the bundle sells; (iii) an `own_price` bundle without a matching-currency component set. Currency selection at activation is owned by Subscriptions; the pricing `region` likewise **binds once at activation** — resolved by Subscriptions from the payer's commercial profile (never client-supplied) and frozen with the currency in `pricingSnapshotRef`. The single-currency-per-invoice invariant is enforced here. + +**Rationale**: A subscription must resolve all lines in one bound currency; mixed-currency invoices are invalid. + +**Actors**: `cpt-cf-bss-pricing-actor-billing` + +### 6.5 PriceWindow Linkage and Coverage + +#### Publish-time window coverage + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-pricewindow-coverage` + +For billable usage at time `t`, an active or scheduled `PriceWindow` **MUST** exist for the resolved **canonical scope key** (resolved on the **base** `priceOverlay`) or publish **MUST** fail (no silent fallback), directing the operator to schedule a window. Because `priceEligibility` and `chargeKind` are part of the key, a grandfathered row and its successor, and the components of a hybrid plan, are **distinct keys** that MAY each hold an active window at the same `t` — not an overlap violation. + +**Rationale**: Publishing a billable row without window coverage lets Tariffs step 2 resolve nothing (fails closed). + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Future-gap coverage across scheduled windows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-future-gap-coverage` + +For two or more scheduled `PriceWindow` rows on one scope key, publish validation **MUST** reject any uncovered time interval (gap) between the end of one active/scheduled window and the start of the next for billable periods, directing the operator to close the gap. + +**Rationale**: A coverage gap fails rating closed for everyone during the gap. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Sellability gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-sellability-gate` + +A subscription/purchase **MUST NOT** be created unless **all six sellability predicates** hold for the bound canonical scope key at `t` (joint rule with Subscriptions): (1) an **active** (not merely scheduled) `PriceWindow` covers the key; (2) the plan is addressable in a **committed `CatalogVersion`** (pending fan-out / unwarmed read model is not sellable); (3) plan-level `availableFrom`/`availableTo`, when set, are open at `t`; (4) the plan's lifecycle state is not retired; (5) no GA gate flags the bound market (per-market `not_sellable_ga`, prepaid-execution gate); (6) for a **standalone** line, the offered SKU carries the registry `sellable = true` flag (products gear, D-46) — bundle-**component** references are exempt from (6). The read model **MUST** expose all six predicates; Subscriptions enforces the conjunction. The gate governs the creation of **new** subscriptions only: a renewal of an existing subscription is not a purchase and **MUST NOT** be blocked by it (retirement and passed purchasability dates never kill in-flight renewals; those follow the grandfathering/migration mechanics). For a bundle, the gate evaluates the **conjunction over its components**: the bundle is sellable at `t` only while every referenced component key passes predicates (1)–(5) — components are exempt from (6), which applies to the bundle SKU itself (plus the bundle's own purchasability dates). Optional plan-level `availableFrom`/`availableTo` (the unified pair for **all** billing cycles), when set, **MUST** be validated against window coverage at publish; deferred publish ("publish at T") is out of launch scope. + +**Rationale**: Selling before a plan is both windowed and rateable produces a first rating that resolves no window. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Grandfathering eligibility + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-grandfathering-eligibility` + +The read model **MUST** expose `priceEligibility` (`all_subscriptions` | `new_subscriptions_only` | `existing_grandfathered`), the row's `cohort` (generation), and any `grandfatherUntil` (UTC) for Tariffs step 2; new subscriptions after cutover **MUST NOT** bind to a grandfathered row, and a bound subscription renewing on/after **its generation's** `grandfatherUntil` **MUST** be signalled no longer eligible so Subscriptions re-binds it to the current eligible row (null = indefinite). Generations coexist: each cutover creates a new `cohort` row and prior generations stay live; within the grandfathered class Tariffs resolves the row whose `cohort` matches the subscription's pinned price id. Each generation's `PriceWindow` **MUST** cover through its `grandfatherUntil` **plus the longest billing cycle sold on that key** (open-ended when null) — re-bind happens only at the next renewal after expiry, and the margin keeps every bound period rateable until then; a cutover or window mutation that would violate the bound **MUST** be rejected. + +**Rationale**: Grandfathering and new-only eligibility are first-class commercial rules resolved deterministically. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +### 6.6 Price Overlays and Segment (Customer-Group) Pricing + +#### PriceOverlay authoring and precedence + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-priceoverlay-authoring` + +The catalog **MUST** author and validate `PriceOverlay` rows: scope (partner/orgTier/brand/region/customerGroup/global), adjustment type (markup/discount/fixed — a percent magnitude is a single basis-points value; an **amount-based** magnitude is money and MUST carry **per-currency values covering every currency the overlay's target scope sells**, each at its ISO 4217 minor unit, no implicit FX; a currency added later flags the overlay for remediation), and an explicit `precedence`; it **MUST** reject a duplicate `precedence` within one scope class. Because uniqueness is per class, cross-class ties are possible: the read model **MUST** publish the normative class-specificity tie-break — `customerGroup > partner > orgTier > brand > region > global` — adopted verbatim by Tariffs, and authoring **MUST** warn on an equal-precedence cross-class pair with overlapping targets. A `PriceOverlay` adjustment **MAY** be effective-dated via its own `[effectiveFrom, effectiveTo)` interval (validated per scope + adjustment target, not on the canonical price-row key), and **MUST** declare its tax basis (or explicitly delegate to Tariffs) or fail publish. Every `PriceOverlay` **MUST** carry a `disclosure` flag — `restricted` (default, fail-closed: the overlay and its existence are never exposed on consumer-facing enumeration or preview and materialize only in the member payer's own evaluation context) or `public` (Presentation / the Tariffs effective-price preview MAY disclose the adjusted price); operator/service reads are unaffected. Precedence/stacking **evaluation** remains in Tariffs. Every committed `PriceOverlay` mutation is a **publish unit through the engine** (§17.5): it becomes consumer-visible only via a committed `CatalogVersion` + read-model warm, within the propagation SLO — never "with the next unrelated plan publish". + +> **D-42 (PROPOSED 2026-07-13, flagged for veto)** — reopens F-88's single-adjustment shape: a `PriceOverlay` MAY instead hold **per-plan adjustment lines** keyed `(planId, targetSku?)`, each with its own kind + magnitude (most-specific-wins within a list; class rank still stacks across lists), so one segment deal can carry different rates per plan without *N* sibling overlays. Still strictly adjustment-only (different *structures* stay Future / separate-plan). Prototyped in Pricing Studio; precedence uniqueness and per-currency coverage re-attach per line. Not normative until Product/Finance rule. + +**Rationale**: Deterministic overlay ordering and unambiguous tax basis require authored, validated precedence. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### PriceOverlay referential integrity + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-priceoverlay-referential-integrity` + +A `PriceOverlay` row whose scope references a plan or SKU that is not published **MUST** be rejected with a clear business error and **MUST NOT** be exposed in the read model. + +**Rationale**: Overlays referencing draft/unpublished targets would resolve against non-existent base rows. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### Customer-group segment pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-customer-group-pricing` + +The catalog **MUST** author a `customerGroup`-scoped `PriceOverlay` (adjustment per group x region), own the **group taxonomy** (BSS-governed, validated at authoring), and publish an **effective-dated, audited membership** record on the payer's commercial profile (resolved via `payerTenantId`) into the read model — **at most one active membership per payer across all groups** (a conflicting enrollment is rejected; a transfer is one atomic audited **move**), so the resolved group is always unique; the **resolved group** **MUST** be frozen in `pricingSnapshotRef`. Every committed membership mutation is a **publish unit through the engine** (§17.5) — consumer-visible only via a committed `CatalogVersion` + warm (registry batching coalesces bulk enrollments), so a renewal after the commit always sees the membership. A membership change is renewal-aligned by default (immediate re-resolution is an explicit material change); a group discount/move affecting many payers is a **material change**, and all membership changes **MUST** be audited. Entirely different **tier structures** per group are out of launch scope. Overlay evaluation is owned by Tariffs. + +**Rationale**: Segment pricing must be governed, audited, and deterministically frozen without changing tenant topology. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +### 6.7 Publish Validation, Approval, and Events + +#### Fail-closed publish validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-publish-validation-failclosed` + +Publish **MUST** be blocked (and `PlanPublished` **MUST NOT** be emitted, nor Rating read models warmed) when validation detects any invalid condition in the aggregate fail-closed set (§17.4 price-validation rules and §12 AC #26), including ambiguous meter, overlapping/gapped tiers, unspecified `modelKind` on a tiered row, evaluation-policy fields on a non-usage row, invalid `package`/prepaid config, missing `PlanTier`/descriptors, invalid hybrid, currency precision over the ISO 4217 minor unit, unknown region/brand, add-on dependency cycle, uncovered window gap, a recurring row missing `billingTiming`, a non-one-time setup row, or a `minQtyThreshold` with no declared floor type (or a `usage` floor with no declared fallback). + +**Rationale**: A single aggregate fail-closed gate prevents any ambiguous configuration from reaching production. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Two-person rule and segregation of duties + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-approval-two-person` + +A **material change** (above the configured threshold, or a first publish with no baseline) **MUST** require **one independent approver** before `PlanPublished` (submitter + 1 approver = two distinct principals); the submitter **MUST NOT** satisfy the approval slot, and any self-approval attempt **MUST** be rejected and audit-logged. The submission **MUST** pin the exact submitted content (content hash); any mutation of the subject while pending **MUST** void the approval (back to draft, fresh submit), and an approval decision **MUST** verify the pinned content — a reviewer can only approve exactly what they reviewed. The approver's authorization scope **MUST** cover every region/brand the pinned change touches. Submitter and approver identities and timestamps **MUST** be logged; a rejection returns the Plan to `draft` with reason and notifies the submitter. + +**Rationale**: Unauthorized or self-approved pricing change is a financial-fraud risk. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-reviewer` + +#### Approval-threshold policy + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-approval-threshold-policy` + +The tenant approval-threshold policy **MUST** express materiality as an absolute amount or percentage delta **per currency**; for a multi-currency change each affected row's delta is compared in its **own** currency and the rule trips if **any** row exceeds its threshold. Mutating the threshold policy is itself **always material** (direction-agnostic): the change **MUST** take effect only after an independent second approver confirms it through the standard approval workflow — the two-person rule's foundation is never single-person-editable; in-flight submissions keep their submit-time materiality. The system **MUST** apply the two-person rule by default and **MAY** auto-publish with no independent approver **only if** a threshold is explicitly configured **and** the change is below it **and** it is not a first publish. A row with no prior baseline of its own (a new currency/region/phase/chargeKind key added to a published plan) has no computable delta and is therefore **always material** (fail-safe). + +**Rationale**: Fail-safe materiality (two-person rule unless explicitly below an configured threshold) prevents silent large changes. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-reviewer` + +#### Publish fan-out atomicity and degraded handling + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-publish-fanout-atomicity` + +Once the version commits (`CatalogVersionPublished`), the system **MUST** either complete read-model warming within the 5s SLO via retry or mark the publish degraded and emit `PlanPublishDegraded`; it **MUST NOT** leave a state where charges can resolve against a partially-published plan. The pre-commit batching delay (`PlanPublished` -> `CatalogVersionPublished`) is governed by the max batching-delay SLO, not by `PlanPublishDegraded`. + +**Rationale**: Partial publish must never expose a rateable-but-incomplete plan. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Frozen event-name contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-event-contract` + +The catalog **MUST** emit `PlanCreated`, `PlanUpdated`, `PlanPublished`, `PlanRetired` (and, conditionally, `PlanMigrationScheduled`, `PlanPublishDegraded`, `BundleUpdated`, `PriceCreated`, `PriceUpdated`) with the frozen names in §4 (no aliases or renames without a versioned contract change), ordered per `(tenantId, aggregateId)` where the manifest applies, carrying correlation/idempotency keys so consumers can dedupe (at-least-once delivery). + +**Rationale**: Downstream cache warming and eligibility depend on a stable, ordered, dedupable event contract. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### CatalogVersion increment on publish + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-catalogversion-increment` + +On every `PlanPublished` the plan's content **MUST** become addressable in a `CatalogVersion` with the registry as the **sole** incrementer; the registry **MAY batch** multiple approved publishes into one discretionary catalog publish (not one dedicated version per `PlanPublished`). `PlanPublished` carries a **pending** version reference; the committed `CatalogVersion` is finalized when the registry emits `CatalogVersionPublished`, and `pricingSnapshotRef` **MUST** pin that committed version (§17.5). + +**Rationale**: Determinism requires pinning the exact committed version the registry batches the plan into. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-registry` + +#### Consumer read-model resolution and monotonicity + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-consumer-readmodel-resolution` + +Consumers **MUST** resolve `{skuId, planId, priceId}`, model kind, ordered tier bands, and evaluation-policy fields exactly as published, from the committed `CatalogVersion`, **without** reading draft state and **without** substituting any default for an absent evaluation-policy field (absence must have failed publish). The read model **MUST** be **monotonic per `CatalogVersion`** (ignored until its `CatalogVersionPublished` + warm-completion marker); a rating run **MUST** pin one `CatalogVersion` for its entire duration, and at pin time the pinned version **MUST NOT** lag the newest completed version by more than 5s. + +**Rationale**: Deterministic, monotonic resolution is the basis of reproducible rating. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Pricing snapshot on billable artifacts + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-pricing-snapshot` + +Charge artifacts and `BillableItem`s **MUST** carry catalog refs and `pricingSnapshotRef` per manifest §4.1; posted invoice periods **MUST NOT** re-query mutable catalog rows. Publish **MUST** stamp identifiers sufficient for the manifest `pricingSnapshotRef`. + +**Rationale**: Reproducibility and posted-financial immutability require a complete frozen snapshot ref. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +### 6.8 Plan Lifecycle: Versioning, Retirement, Migration + +#### Plan versioning and immutable history + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-plan-versioning` + +A price or tier change on a published Plan **MUST** version the Plan and create **new** immutable `Price` records (prior rows retained as history); existing subscriptions **MUST** continue on their frozen snapshot until renewal or migration. Subscriptions bound via `existing_grandfathered` are the exception: they are **live-resolved** against an immutable grandfathered row (§17.5). + +**Rationale**: Immutable price history and frozen snapshots protect active subscribers from silent repricing. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Published rows are append-only + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-published-rows-append-only` + +A **published** price row on the canonical scope key **MUST NOT** be deleted (whether or not subscriptions are bound); published rows are append-only history. The operator **MUST** use supersession + grandfathering (or retirement + migration) instead. Deletion is available **only** for `draft` rows that were never published. + +**Rationale**: Quotes, previews, exports, and overlays reference published rows; there is no deletion event to fan out. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Supersession within a scope key + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-supersession` + +Supersession is versioning scoped to one canonical scope key: it **MUST** create a new immutable `Price` row (never mutate in place) and open/close the corresponding `PriceWindow` rather than overlap it, operating within one `priceEligibility` class, one `cohort`, and one `chargeKind`. At most **one pending approval unit** (supersession or grandfathering cutover) may exist per canonical scope key at a time — a concurrent second submission **MUST** be rejected with a conflict. An `existing_grandfathered` row is **immutable in price** and **MUST NOT** be superseded; the only permitted mutation is setting or **tightening** `grandfatherUntil` (never loosening, never the price), which is a material change. + +**Rationale**: Scope-key-bounded supersession preserves non-overlap and grandfathered-price stability. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Plan retirement + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-plan-retirement` + +Retirement **MUST** block **new** subscriptions to that `planId`, preserve existing subscription snapshots, emit `PlanRetired`, and **trigger the window-cancellation flow** (owned by this PRD's design since the consolidation; emitting `PriceWindowCancelled` per cancelled not-yet-active window, driving its cache-eviction path) — it **MUST NOT** merely mark windows invalid. The operator **MUST** be warned of cancelled windows before confirm. Retirement **MUST** be rejected (references enumerated) while the plan is referenced as a bundle component or as an add-on price-override target; the referencing composition is remediated first. + +**Rationale**: Safe retirement blocks new sales while preserving in-flight subscribers and cleaning future windows. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Scheduled migration + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-scheduled-migration` + +Scheduled migration to a published target Plan **MUST** emit `PlanMigrationScheduled` for Subscriptions to create effective-dated `PlanLink`s without posted invoice mutation. Retry **MUST** be idempotent (no duplicate `PlanLink` requests for already-processed subscriptions). Cancellation **MUST** be possible for a `scheduled` **or partially-executed** migration (halting further processing; already-migrated subscriptions unaffected; only a completed run is uncancellable), and propagation is a **state handshake**: Subscriptions re-reads the schedule state before beginning (and while continuing) execution. At execution start the contract-lock set and boundary deltas **MUST** be re-resolved against fresh state (a lock is never broken however stale the schedule). A migrated subscription enters the target's **first non-trial phase** — a migration never grants a new `trial`. + +**Rationale**: Migration must be re-trigger-safe, cancellable, and free of posted-invoice mutation. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Migration safety deltas and legacy snapshot synthesis + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-migration-safety` + +Migration configuration **MUST** exclude contract-locked subscriptions (reported, lock never broken) and surface entitlement deltas and add-on deltas (subscribers whose add-ons become invalid, or who lack a required add-on) as blocking deltas. It **MUST** also surface **boundary deltas** as blocking: a target lacking a published row for a subscription's frozen `(currency, region)` pair with matching frequency (cross-currency/region/frequency moves are cancel + new, never in-place); subscribers bound to a grandfathered row are surfaced informationally (the migration ends their legacy pricing). For a legacy subscription with no `pricingSnapshotRef`, the system **MUST** synthesize and freeze a snapshot from the published plan state **as of the trigger instant (UTC), frozen at execution**, recorded as `migrated-origin` with a provenance record (source `planId`/revision, resolved price ids, snapshot instant, trigger `migration`|`first-rating`, acting principal). The `first-rating` trigger **MUST NOT** synthesize inline on the rating path: the rating line fails closed into the rating exception path, synthesis runs as a separate audited step, and rating retries against the frozen result. + +**Rationale**: Migrations must not break contract locks, overflow entitlements, or leave subscriptions un-snapshotted. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Contract-locked plan protection + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-contract-locked-protection` + +While an active contract references a Plan revision, structural plan mutation **MUST** be rejected, directing the operator to a new Plan revision or contract expiry; contract-locked subscriptions **MUST** be excluded from scheduled migration. + +**Rationale**: Contract-locked commercial terms must not be mutated out from under an active agreement. + +**Actors**: `cpt-cf-bss-pricing-actor-contracts` + +#### Concurrent-edit conflict detection + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-concurrent-edit` + +A submit with a stale version/ETag **MUST** be rejected with a conflict requiring refresh; an interactive edit targeting a row held under an in-flight bulk-import optimistic lock **MUST** fail with a conflict naming the bulk operation. The system **MUST NOT** silently overwrite either change. + +**Rationale**: Optimistic concurrency prevents lost updates between bulk and interactive edits. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +### 6.9 Consumer Contracts (Subscriptions / Rating / Billing) + +#### Proration input contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-proration-input-contract` + +Each recurring price row **MUST** publish `billingAnchorPolicy`, `prorationBasis` (the canonical enum, §1.4, adopted verbatim by Tariffs), and `creditOnDowngrade` (source-row semantics on a downgrade, per §17.6; `true` with `prorationBasis = none` **MUST** fail publish as contradictory), frozen in `pricingSnapshotRef`; Subscriptions owns the change boundary/mode, the rating gear the proration math (rating §6.11/§17.2). A mid-cycle change crossing currency, region, or billing frequency **MUST** be rejected for in-place proration (handled as cancel + new subscription) and the operator **MUST** be warned that in-place credit is forfeited. + +**Rationale**: Deterministic proration requires the same frozen inputs on the catalog and Subscriptions/Tariffs sides. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Billing timing on recurring rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-billing-timing` + +Every recurring price row **MUST** carry `billingTiming` (`in_advance` | `in_arrears`), frozen in `pricingSnapshotRef`; usage rows are implicitly `in_arrears`. Publish **MUST** fail if a recurring row omits `billingTiming`; a hybrid plan **MAY** combine an `in_advance` base row with `in_arrears` usage rows (Billing derives deferral policy from this). + +**Rationale**: Billing's deferral policy depends on an explicit, frozen billing timing. + +**Actors**: `cpt-cf-bss-pricing-actor-billing` + +#### Entitlement grant set + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-entitlement-grant-set` + +The published read model **MUST** include the plan's entitlement grant set (feature flags, quotas) or its `PlanTier`-resolved reference for Subscriptions provisioning; publish **MUST** fail if a referenced feature, quota, or `PlanTier` policy is undefined in the registry. This PRD does **not** define entitlement semantics. A **phased** plan **MAY** publish the grant set **per phase** (a `phase→grant-set map`, mirroring the `phase→price map`; D-41): each phase resolves its own feature flags/quotas so a trial phase can confer smaller caps than evergreen. Absent per-phase entries the grant set is plan-level (`PlanTier`-driven) as before; Subscriptions resolves the grant set for the **active phase at `t`** and enforces it. The catalog publishes the map; it never enforces. + +**Rationale**: Subscriptions can only provision/enforce entitlements from a complete, published grant set; time-boxed trials with tighter quotas than the paid phase need the grant set to vary by phase just as price does. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Plan-change contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-plan-change-contract` + +For a plan that participates in self-service changes, the read model **MUST** publish `allowedChangeTargets` (**explicit** target `planId`s — rule-based targets are not authorable at launch, Future scope) and a `comparabilityRank` (or an authoritative published `PlanTier` ordering) so Subscriptions can classify upgrade/downgrade/switch and constrain targets. Publish **MUST** validate every listed target is published **and** itself carries a `comparabilityRank` (or is covered by the authoritative ordering) — ranks form one tenant-wide scale, so an A→B classification is always computable. Every edge **MUST** be published with its **boundary classification** — `in_place` (same currency/region coverage + frequency) or `cancel_plus_new` (crosses a boundary; disclosed to the payer before execution). An edge whose target is later retired is **inert** — Subscriptions re-checks the target's lifecycle state at change time. Absence of `allowedChangeTargets` **MUST** mean **no self-service change** (fail-safe), not any-to-any; enforcement stays in Subscriptions. + +**Rationale**: Subscriptions must not hard-code ordering or allow moves Finance never approved. + +**Actors**: `cpt-cf-bss-pricing-actor-subscriptions` + +#### Rating compatibility contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-rating-compatibility` + +Published plans **MUST** expose stable `{skuId, planId, priceId}` on all downstream artifacts, persist `modelKind` (and `quantitySource` for `per_unit`, `packageSize`/`packagePrice` for `package`), persist `billingGranularity` on usage rows at publish (tiered usage rows **MUST** additionally persist `tierAggregationWindow`), and satisfy meter mapping (one `meteringUnit` per usage revision; a derived meter counts as one output unit). No monetary charge is computed here. + +**Rationale**: Rating consumes frozen inputs; missing or defaulted fields would break deterministic charging. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +### 6.10 Advanced Pricing Primitives + +#### Reserved-capacity price + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-reserved-capacity` + +A `usage` price row **MAY** carry `reservedRate` and `reservationFlavor` (`consumption` | `capacity`) as attributes on the single row (not a second row), frozen in `pricingSnapshotRef` (§17.7). Tariffs evaluates the reservation (step 6) sourcing the self-service reserved rate from the catalog snapshot; the reserved/allocated quantity is supplied at runtime and the catalog **MUST NOT** meter, allocate, or compute the charge. On a tiered row the matched/allocated reserved quantity is **excluded** from the tier counter `Q` — only the on-demand remainder enters the bands; the reservation joint fixture **MUST** include a tiered-remainder scenario. Negotiated RI-style rates stay in Contracts. + +**Rationale**: Self-service reserved rates need an authoring home without double-pricing the meter. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Included allowance + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-included-allowance` + +A `usage` price row **MAY** declare `includedAllowance = { quantity N > 0 (in the row's billable units), rolloverPolicy ∈ {none (default), carry(maxPeriods ≥ 1)} }`, frozen in `pricingSnapshotRef` (D-45; supersedes the F-32 "$0-first-band only" stance). Publish **MUST compile** the declaration: for `rolloverPolicy = none`, into the `$0` first band `[0, N)` of the row's band set **plus** a first-class allowance marker (display "includes N units"; reporting separates included from billed) — evaluation is exactly the existing band math, no rating change; for `carry(maxPeriods)`, into a per-period **promotional grant** scoped to the row's meter under the D-43 machinery (issued free, `applicability` = this meter, expiry encodes the carry horizon; exact grant materialization is Design) — drawdown execution is **Billing-owned** and the catalog holds no balance. Publish **MUST fail** on: `includedAllowance` on a non-`usage` row; a row combining an authored `$0` first band with `includedAllowance` (double-free); a non-`sum` row (`aggregationFunction ≠ sum` — level-meter allowance is Future: the boundary in level·granule units varies with period length); `quantity ≤ 0`. Per-seat-scaled allowances are a named Future decision gate. + +**Rationale**: Existing SKUs carry "N included, then rate" semantics; the $0-band covered the math but not display/reporting, and rollover was inexpressible. Compilation keeps both executors unchanged — bands for the math, grants for the cross-period state — first-class authoring with zero new evaluation machinery. Allowance netting at the usage source is prohibited by the same doctrine as D-44 (the commercial rule lives in the catalog; sources emit raw usage). + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin`, `cpt-cf-bss-pricing-actor-billing` + +#### Prepaid credit grant + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-prepaid-credit-grant` + +A plan **MAY** declare a prepaid credit grant (`grantAmount` > 0, `creditUnit` = currency or **published** `meteringUnit`, `category` = `prepaid` (default)|`promotional`, `price` per `(currency, region)` (`category = prepaid` only), `expiryPolicy` = `never`|`days(N>0)`, `autoRechargeAllowed` (`prepaid` only), `applicability` = `all_usage` (default)|set of published usage meters, `drawdownPriority` = optional int ≥ 0), frozen in `pricingSnapshotRef` (§17.7). Publish **MUST** fail on an unset `expiryPolicy`, an unpublished `creditUnit`, an unscoped grant price for a multi-`(currency, region)` plan, a `promotional` grant carrying price rows or `autoRechargeAllowed`, or an `applicability` entry that is unpublished or not a usage line of the plan (a metered `creditUnit` additionally bounds `applicability` to that unit's meters); publish **MUST materialize** the resolved `applicability` into the snapshot — the executor never infers scope (D-43); `days(N)` anchors at grant issuance (the purchase or recharge instant, UTC); grant-price, `category`, `applicability`, and `drawdownPriority` changes follow the material-change policy. `drawdownPriority` is an authored **default**: the effective order across the grants an account holds is **Billing-owned**, resolved deterministically as `drawdownPriority` → `promotional` before `prepaid` → earlier expiry → earlier issuance → `grantId` (D-43). The catalog **MUST NOT** persist any balance, compute drawdown, or order live balances. + +**Rationale**: Wallet-style selling needs a defined grant primitive with balance execution owned by Billing/Rating. + +**Actors**: `cpt-cf-bss-pricing-actor-billing` + +#### Derived (composite) meter + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-derived-composite-meter` + +A price row **MAY** use a derived (composite) meter; publish **MUST** persist >= 2 **published** constituent `meteringUnit` ids, the formula expression **as data**, and a single **output unit**, and **MUST** fail if any constituent unit is unpublished or the formula self-references. The definition **MUST** be frozen in `pricingSnapshotRef`; Tariffs evaluates the formula and the catalog **MUST NOT** compute the result. + +**Rationale**: Multi-unit offerings (e.g. VM = vCPU + RAM) need a composable, frozen definition without computing math here. + +**Actors**: `cpt-cf-bss-pricing-actor-rating` + +#### Interim discount reference hook + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-discount-ref-hook` + +An optional `discountRef` **MUST** be validated for referential integrity (resolves to a registered external discount instrument) and persisted on the snapshot; the catalog **MUST NOT** author, evaluate, or stack the discount, and its absence **MUST NOT** block publish. Evaluation is owned by Tariffs/Promotions. + +**Rationale**: A day-1 discount hook must be sellable without the catalog owning discount authoring/evaluation. + +**Actors**: `cpt-cf-bss-pricing-actor-promotions` + +#### Minimum-quantity floor typing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-min-qty-floor` + +A `minQtyThreshold` **MUST** declare its floor type — `purchase` (Subscriptions rejects orders below the floor) or `usage` (Tariffs/Rating treats usage below the floor as ineligible and fails closed, never silently zero-rated). A `usage` floor **MUST** additionally declare its fallback on the row; at launch the only supported fallback is **`exception`** (the below-floor usage line fails closed into the rating exception path — visible and resolvable; richer fallbacks are Future scope). Publish **MUST** reject a `minQtyThreshold` with no declared floor type and **MUST** warn if it falls inside a non-zero-priced band. The type + value + fallback are frozen in `pricingSnapshotRef`. + +**Rationale**: An untyped floor is ambiguous between order-time rejection and downstream eligibility. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +### 6.11 Operator Efficiency + +#### Plan clone + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-plan-clone` + +Clone **MUST** create a new `planId` in `draft` with copied configuration and **new** price ids, record `clonedFrom`, and **MUST NOT** affect source subscriptions. `PriceWindow` schedules are runtime state, **not** configuration — they are never cloned, and the clone's billable rows cannot publish until fresh windows are scheduled (window-coverage validation applies normally). `priceEligibility` and `grandfatherUntil` **MUST** reset to defaults (not copied), contract locks **MUST NOT** be copied, and `discountRef` **MUST** be copied only if it still resolves to a registered instrument (else dropped with an operator notice). `existing_grandfathered` rows are lifecycle state, not configuration — they are **not** cloned (the `all_subscriptions` successor row carries the going-forward price); superseded/closed historical rows are likewise not copied. + +**Rationale**: Cloning accelerates authoring without carrying eligibility/lock state that must be re-decided. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Bulk price import + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-bulk-price-import` + +Bulk price import **MUST** validate all-or-nothing **pre-commit** (any invalid row blocks the whole batch with a per-row report); at **commit** it **MUST** take optimistic per-row locks and a per-row ETag conflict **MUST** fail only that row (batch reported partial; committed rows stand; conflicted rows listed for retry) — never silently overwrite. A material batch's approval pins **per-row content**; the committed subset **MUST** be a subset of the approved set, and a retry of conflicted rows with unchanged content reuses the original approval (a changed row starts a fresh approval). + +**Rationale**: Annual repricing must be efficient yet safe against concurrent manual edits. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Mass repricing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-mass-repricing` + +A mass price adjustment over N rows **MUST** be idempotent (re-run-safe after partial failure) and emit deduplicated events; it **MAY** coalesce into one or few `CatalogVersion`s. Repricing selectors **MUST** exclude `existing_grandfathered` rows (immutable in price); an explicit inclusion fails that row with a per-row error — never a silent skip, never a reprice. The throughput SLO **MUST** be back-calculated from the tenant worst-case row count against an agreed maintenance window (provisional >= 50 rows/sec; Design confirms against the worst case). + +**Rationale**: Large repricing runs must be safe to retry and must not flood consumers with duplicate events. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Mutation idempotency keys + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-mutation-idempotency` + +A plan/price create/update call carrying a client idempotency key **MUST** return the original result on retry (no duplicate draft plan or price row) for a documented TTL; a key reused after the TTL **MAY** be a new request. An idempotency-key replay during an active bulk lock **MUST** return the original completed result regardless of current lock state. + +**Rationale**: Safe retries require idempotent mutation semantics with a defined replay window. + +**Actors**: `cpt-cf-bss-pricing-actor-finance-manager` + +#### Price history and export + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-fr-price-history-export` + +The system **MUST** return chronological immutable price-history records with actor and effective dates under Auditor/Finance filters and **MUST** support export within p95 <= 5s for 100 records. + +**Rationale**: Auditors and Finance must reconcile contract and billing disputes from complete immutable history. + +**Actors**: `cpt-cf-bss-pricing-actor-auditor` + +### 6.12 Access Control and Governance + +#### RBAC deny-by-default + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-rbac-deny-by-default` + +Plan mutate APIs **MUST** be deny-by-default: a mutation is permitted only to the roles the role matrix grants the relevant `(resource, action)` (CatalogAdmin/ProductManager/FinanceManager for authoring mutations; FinanceReviewer for approval decisions and threshold policy; Auditor is read-only), and denied attempts **MUST** be audit-logged; **read/preview** APIs **MUST** also be deny-by-default (an unlisted-role principal is denied unless it holds an explicit **catalog-preview read grant**, region/brand-scoped by IdP claims). The historical-import (backdating) capability **MUST** be a distinct restricted grant, not a default role. + +**Rationale**: Pricing data is commercially sensitive; both mutation and preview must be least-privilege. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### Tenant and brand isolation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-tenant-brand-isolation` + +The system **MUST** enforce tenant isolation and scope brand/region per IdP claims at the gateway (authorization/display scope). Pricing `region` is **decoupled** from the IdP authorization region: authoring or mutating a price row scoped to a pricing `region` the user's authz scope does not grant **MUST** be denied and audit-logged. + +**Rationale**: Cross-tenant leakage is a critical incident class; authz region must not be conflated with pricing region. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### Historical-import (backdating) governance + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-historical-import-governance` + +The historical-import path that permits past `availableFrom`/effective dates **MUST** require a **restricted role** and a **mandatory reason**, **MUST** validate every imported row against the same row-shape rules as regular authoring (taxonomies, precision, scope-key uniqueness, model shape — an import can never create a row authoring would reject), **MUST** be an **always-material change** (independent second approver required — backdated rows shape `migrated-origin` snapshots that rating consumes going forward), **MUST** be fully audited, and **MUST NOT** generate or re-open any downstream billable charge window. It is the **only** sanctioned backdated reference path (e.g. for legacy snapshot synthesis). + +**Rationale**: Backdating is powerful and fraud-adjacent; it must be restricted, justified, audited, and side-effect-free downstream. + +**Actors**: `cpt-cf-bss-pricing-actor-catalog-admin` + +#### Audit completeness and tamper-evidence + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-fr-audit-completeness` + +Any plan/price mutation **MUST** record actor (as a **pseudonymous principal id** — no display names/emails, keeping operator PII out of the long-retention store), timestamp, before/after version, and approval trail; price history **MUST** be retained **>= 7 years** (tenant/jurisdiction-configurable; the **maximum** applicable minimum where several apply) and stored **append-only / tamper-evident** (hash-chained in the transactional store; optionally anchored to external WORM) so prior versions cannot be mutated or deleted within the retention window. + +**Rationale**: Tamper-evidence and jurisdiction-aware retention are compliance gates. + +**Actors**: `cpt-cf-bss-pricing-actor-auditor` + +## 7. Non-Functional Requirements + +### 7.1 NFR Inclusions + +> Provisional targets below (availability/DR, mass-repricing throughput, size caps, idempotency-key TTL) are working assumptions that MUST be ratified before Design lock (no bare placeholders may ship); see §15. + +#### Plan mutation latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-mutation-latency` + +Plan create or update (end-to-end persistence and validation) **MUST** complete within **2 seconds at p95**. + +**Threshold**: p95 <= 2s for plan create/update. + +**Rationale**: Operator authoring throughput depends on responsive mutation. + +#### Price field validation latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-validation-latency` + +A single price row save (currency/region/tier validation) **MUST** complete within **200ms at p95**. + +**Threshold**: p95 <= 200ms per price-row validation. + +**Rationale**: Inline validation must feel immediate in the tier/price editor. + +#### Publish propagation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-publish-propagation` + +Plan definitions **MUST** be visible to Rating cache warming within **5 seconds at p95** of `CatalogVersionPublished`; the registry **MUST** bound the delay from `PlanPublished` (pending) to `CatalogVersionPublished` (committed) by a stated **max batching-delay SLO**. + +**Threshold**: p95 <= 5s from `CatalogVersionPublished` to read-model visibility; max batching-delay SLO ratified with the registry. + +**Rationale**: Rating and portal depend on timely propagation of committed catalog content. + +#### Plan read latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-read-latency` + +Catalog browse or plan-detail read served from the read model within a tenant partition **MUST** have p95 latency **under 100ms**, holding at the 20-currency floor. + +**Threshold**: p95 < 100ms per tenant partition (at >= 20 currencies/plan). + +**Rationale**: Rating and portal reads are on the hot path. + +#### Event propagation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-event-propagation` + +`PlanCreated`/`PlanUpdated` (and the other producer events) **MUST** reach downstream consumers within **5 seconds at p95**, carry correlation/idempotency keys, and be safe to redeliver (at-least-once; consumers dedupe by idempotency key). + +**Threshold**: p95 <= 5s to consumers; duplicate redelivery safe. + +**Rationale**: Downstream cache warming and eligibility depend on timely, dedupable events. + +#### Multi-currency scale + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-multi-currency-scale` + +The system **MUST** support **at least 20 currencies per plan as a guaranteed floor** (tested at 20), and the read SLO (p95 < 100ms) **MUST** hold at that floor, not only at nominal load. + +**Threshold**: >= 20 currencies/plan with read p95 < 100ms sustained. + +**Rationale**: Global plans carry many per-market rows; the read path must not degrade. + +#### Mass-repricing performance and idempotency + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-nfr-mass-repricing-throughput` + +A mass price adjustment over N rows **MUST** be idempotent (re-run-safe) and emit deduplicated events; the throughput SLO **MUST** be back-calculated from the tenant worst-case row count (plans x currencies x regions) against an agreed maintenance window. + +**Threshold**: Provisional >= 50 rows/sec (Design confirms against the worst case, not the provisional figure). + +**Rationale**: Annual/bulk repricing must complete within a maintenance window and be safe to retry. + +#### Read-model availability and disaster recovery + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-availability-dr` + +The published read model **MUST** meet a stated availability SLO and disaster-recovery objective; during a read-model outage, charge resolution **MUST NOT** resolve from an unavailable or partially-warmed read model and **MUST** fail closed (frozen `pricingSnapshotRef` resolution is unaffected). + +**Threshold**: Provisional availability >= 99.9% per tenant partition; RPO <= 5 min; RTO <= 30 min (Architecture ratifies before Design). + +**Rationale**: Rating has a hard real-time dependency on the read model; best-guess pricing under lag is a show-stopper. + +#### Audit retention and tamper-evidence + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-nfr-audit-retention` + +Audit and price-history records **MUST** be retained **>= 7 years** (tenant/jurisdiction-configurable; the **maximum** applicable minimum where several apply) and stored **append-only / tamper-evident** (hash-chained in the transactional store; optionally anchored to external WORM) so prior versions cannot be mutated or deleted within the retention window. + +**Threshold**: >= 7-year retention (configurable); 100% append-only/tamper-evident storage. + +**Rationale**: Financial audit and dispute resolution require immutable, long-lived history. + +#### Data residency for price and audit data + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-nfr-data-residency` + +Price and audit/history data for a residency-bound tenant **MUST** stay within the configured jurisdiction's residency boundary and **MUST NOT** replicate a row outside **any** of its residency jurisdictions' boundaries, consistent with the jurisdiction-configurable retention. + +**Threshold**: Zero rows replicated outside a residency jurisdiction boundary. + +**Rationale**: Data-residency violations are compliance incidents. + +#### Plan and tier size limits + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-nfr-size-limits` + +The system **SHOULD** enforce configurable soft caps on tier-band count per row and price-row count per plan, emitting a publish warning above the cap, to protect the read SLO and mass-repricing SLO from pathological plans. The custom-interval cap referenced by `fr-custom-frequency` (AC #84) belongs to the same limit set. + +**Threshold**: Provisional <= 100 tier bands/row, <= 500 price rows/plan; custom-interval cap provisional `customEveryNDays` <= 366, `customEveryNMonths` <= 24 (tenant-configurable; Design may tune). + +**Rationale**: Unbounded plan size degrades the read and repricing hot paths. + +### 7.2 NFR Exclusions + +Explicit dispositions for domains not owned by this PRD (no silent omissions): + +- **Tax computation / scheme-determination performance**: Not applicable — Tax Engine / Billing; the catalog persists only the display basis and `taxCategory` reference. +- **Proration / plan-change math performance**: Not applicable — the rating gear evaluates proration (Subscriptions owns the boundary/mode); this PRD publishes the inputs. +- **FX / overlay evaluation performance**: Not applicable — Tariffs owns FX and overlay stacking; the catalog performs no FX or evaluation. +- **Prepaid balance-ledger / drawdown performance**: Not applicable — Billing / Rating; the catalog defines the grant only, never tracks balance. +- **Frontend UX performance / accessibility (WCAG) / i18n rendering**: Not applicable to this backend PRD — owned by the corresponding frontend DESIGN (Presentation renders localization). + +## 8. Five Quality Vectors Analysis + +| **Quality Vector** | **Show-Stopper Requirements** | **Rationale** | +|--------------------|-------------------------------|---------------| +| **Efficiency** | Self-service plan/tier config; clone; bulk import; >= 90% reduction in manual IT for standard plans | Faster commercial packaging for partners and product lines | +| **Reliability** | Fail-closed publish; consumer-side read-model determinism + monotonicity per `CatalogVersion`; read-model availability/DR for Rating; cross-PRD conformance fixtures (tier-boundary, proration); fan-out atomicity (retry-to-SLO or `PlanPublishDegraded`); immutable price history; deterministic ids on all charges; migration without invoice mutation | Billing incidents from bad config, partial publish, read-model outage, or cross-PRD seam drift are show-stoppers | +| **Performance** | Read p95 under 100ms; publish fan-out under 5s; validation under 200ms for price save | Rating and portal depend on the catalog read path | +| **Security** | Two-person rule with segregation of duties (no self-approval); RBAC incl. scoped historical-import capability; tenant/brand/region isolation; audit on deny; append-only/tamper-evident audit and price history; per-jurisdiction data residency | Unauthorized or self-approved pricing change is a financial-fraud risk; tamper-evidence and residency are compliance gates | +| **Versatility** | One-time, recurring (monthly to custom cycles), usage, hybrid, per-seat; add-ons; bundles; 20+ currencies; phases; grandfathering; migration | Required for IaaS/PaaS/SaaS and marketplace | + +**Note**: Plan retirement and migration contributes to **Reliability** and **Versatility**. Billing descriptors contribute to **Reliability** (invoice reproducibility). + +## 9. Public Library Interfaces + +> Plan & Price Modeling is a backend catalog capability, not a client library. Interfaces below are high-level contracts; concrete API schemas, endpoints, and DDL belong in DESIGN. + +### 9.1 Public API Surface + +#### Plan/Price authoring and publish contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-interface-authoring-publish` + +**Type**: conceptual authoring + publish contract (shape in Design) + +**Stability**: stable (contract intent), schema unstable (Design owns) + +**Description**: Create/update/clone plans and price rows in `draft`, run fail-closed publish validation, submit for approval (two-person rule), and publish — emitting the frozen event set and requesting a `CatalogVersion` increment. Accepts client idempotency keys; enforces optimistic concurrency (ETag). + +**Breaking Change Policy**: Major version bump for incompatible request/response changes; the publish-validation and event-name contracts are part of the contract. + +#### Published catalog read model + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-interface-catalog-read-model` + +**Type**: conceptual read-model contract (shape in Design) + +**Stability**: stable (contract intent), schema unstable (Design owns) + +**Description**: Exposes, per committed `CatalogVersion`, the complete plan/price read model — `{skuId, planId, priceId}`, model kind, ordered tier bands, evaluation-policy fields, phase->price map, billing descriptors, proration/plan-change/entitlement contracts, reserved-rate and derived-meter definitions — resolvable via `pricingSnapshotRef`, monotonic per version, no draft reads. + +**Breaking Change Policy**: Additive fields only within a major version; removing or redefining a published field is a breaking change. + +#### Effective catalog price preview + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-interface-price-preview` + +**Type**: conceptual preview contract (shape in Design) + +**Stability**: stable (contract intent), schema unstable (Design owns) + +**Description**: Returns the catalog **base** list price, `taxInclusive` flag, tier summary, and trial length (`displayTrialDays`) for a `(region, currency)`; fails closed if no row exists (no implicit FX); indicates that Contract/`PriceOverlays` apply at purchase (Tariffs). Requires a catalog-preview read grant. + +**Breaking Change Policy**: Additive; the base-price-only semantics (no overlay evaluation) are part of the contract. + +### 9.2 External Integration Contracts + +#### Tariffs read-model contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-contract-tariffs-readmodel` + +**Direction**: provided by this PRD to Tariffs + +**Protocol/Format**: model kind, ordered tier bands, `tierAggregationWindow`, `billingGranularity`, `priceEligibility`/`grandfatherUntil`, reserved-rate/flavor, derived-meter definition, `customerGroup` `PriceOverlay` + resolved membership; all frozen in `pricingSnapshotRef` (Design). + +**Compatibility**: No default substitution; absence of a required field must have failed publish; Tariffs never reads draft state. + +#### Subscriptions publish contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-contract-subscriptions` + +**Direction**: provided by this PRD to Subscriptions + +**Protocol/Format**: eligible plans; migration targets + `PlanMigrationScheduled`; phase map + `convertsToPhaseId` + `displayTrialDays`; proration input contract (`billingAnchorPolicy`, `prorationBasis`, `creditOnDowngrade`); plan-change contract (`allowedChangeTargets`, `comparabilityRank`); entitlement grant set; sellability gate (all five predicates: active window, committed version, availability dates, lifecycle state, GA flags) (Design). + +**Compatibility**: Catalog publishes inputs; Subscriptions owns the change boundary/runtime/`PlanLink`; the rating gear evaluates proration; cross-currency/region/frequency mid-cycle change is unsupported at launch (cancel + new). + +#### Registry CatalogVersion increment contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-contract-registry-catalogversion` + +**Direction**: bidirectional with the catalog registry + +**Protocol/Format**: on `PlanPublished` the catalog requests addressability; the registry (sole incrementer) MAY batch approved publishes into one discretionary catalog publish and emits `CatalogVersionPublished`; `pricingSnapshotRef` pins the committed version (Design). + +**Compatibility**: Pending-ref on `PlanPublished`; committed version pinned in the snapshot; increment-trigger taxonomy + max batching-delay SLO ratified with the registry (Open Questions). + +#### Billing descriptor contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-contract-billing-descriptors` + +**Direction**: provided by this PRD to Billing + +**Protocol/Format**: billing descriptor set (invoice line template, tax category, `glCode`, itemization rule) frozen into `CatalogVersion`; `billingTiming` per recurring row (Design). + +**Compatibility**: Sufficient to render an invoice line and post to GL **without** re-querying mutable catalog rows; the agreed minimum field list is fixed with Billing/Payments in Design. + +#### PriceWindow linkage contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-contract-pricewindow` + +**Direction**: internal (consolidated per §15 — the window machinery is owned by this PRD's design; this entry records the dissolved boundary for traceability) + +**Protocol/Format**: the design owns the window store, state machine, scheduling/cancellation API, the UTC activation job, and `PriceWindowScheduled`/`Activated`/`Cancelled`/`Expired` emission (frozen manifest names); price rows attach windows and publish-time coverage is validated in the same gear/database. + +**Compatibility**: Every billable row is window-linkable; retirement runs the same gear's cancellation flow; the canonical scope key (§2.2) is the non-overlap key. Downstream consumers of `PriceWindow*` events are unaffected (names preserved; producer is now this gear). + +## 10. Use Cases + +#### Guided plan creation + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-plan-creation` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Preconditions**: +- A published SKU exists. + +**Main Flow**: +1. Select the published SKU. +2. Choose billing cycle (one-time / recurring / usage / hybrid) and frequency (monthly to custom). +3. Set `PlanTier` and optional ordered phases (trial to evergreen). +4. Configure base price and region/currency scope (per-seat: unit price + quantity source). +5. Add tiers, evaluation policy, or add-on rules. +6. Review the validation summary and submit for approval. + +**Postconditions**: +- A complete plan draft is staged, pending approval. + +**Alternative Flows**: +- **Fail-closed validation**: ambiguous meter, overlapping tiers, missing `PlanTier`/descriptors, or missing evaluation-policy fields block submission with a field-level report. + +#### Tiered pricing editing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-tier-editor` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Preconditions**: +- A usage plan price row exists. + +**Main Flow**: +1. Open the usage plan price row. +2. Add tier rows with bounds and unit prices. +3. Select graduated vs volume. +4. Set `tierAggregationWindow` and `billingGranularity`. +5. Resolve inline validation; save draft or submit. + +**Postconditions**: +- A validated, model-kind-explicit tier set is persisted. + +**Alternative Flows**: +- **Overlap/gap/non-ascending bands or a closed top band**: save/publish fails (the top band is always open). + +#### Hybrid plan composition + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-hybrid-composer` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Preconditions**: +- A usage-capable published SKU exists. + +**Main Flow**: +1. Select the usage-capable SKU. +2. Add a recurring base price section. +3. Add a usage price section with tiers or a flat rate. +4. Validate both sections present. +5. Submit for approval. + +**Postconditions**: +- A hybrid plan with both components under one `planId` is staged. + +**Alternative Flows**: +- **Incomplete hybrid**: publish validation fails until both a recurring and a usage row exist. + +#### Plan approval + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-usecase-approval` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-reviewer` + +**Preconditions**: +- A plan is submitted for publish and flagged material (above threshold or first publish). + +**Main Flow**: +1. Open the pending approvals queue. +2. Review the plan diff and threshold flag. +3. Approve or reject with reason. +4. The system records the independent approver (submitter + 1 approver). + +**Postconditions**: +- On approval `PlanPublished` is emitted; on rejection the plan returns to `draft` with reason and the submitter is notified. + +**Alternative Flows**: +- **Self-approval attempt**: rejected and audit-logged (submitter cannot approve). + +#### Plan retirement and migration + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-usecase-retire-migrate` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Preconditions**: +- A plan with active subscriptions and a published target plan exist. + +**Main Flow**: +1. View the active subscription count. +2. Choose immediate or scheduled retirement. +3. Select the target plan and migration date. +4. Review cancelled future windows and flagged deltas (contract-locked, entitlement overflow, invalid/missing-required add-ons). +5. If cross-currency/region/frequency: confirm the cancel+new warning (in-place credit forfeited). +6. Confirm the migration event. + +**Postconditions**: +- `PlanRetired` (and, on schedule, `PlanMigrationScheduled`) is emitted; Subscriptions creates effective-dated `PlanLink`s; posted invoices are untouched. + +**Alternative Flows**: +- **Contract-locked subscribers**: excluded and reported; the lock is never broken. + +#### Bulk price import + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-bulk-import` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Preconditions**: +- A valid CSV with plan id, currency, region, amount, and taxInclusive columns. + +**Main Flow**: +1. Download the CSV template. +2. Upload the filled file. +3. Review the validation summary. +4. Apply, or fix errors and retry. + +**Postconditions**: +- Valid rows commit under optimistic per-row locks; conflicted rows are reported partial for retry. + +**Alternative Flows**: +- **Any invalid row**: the whole batch is blocked pre-commit with a per-row report. + +#### Price history and export + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-price-history` + +**Actor**: `cpt-cf-bss-pricing-actor-auditor` + +**Preconditions**: +- A plan with price change history exists. + +**Main Flow**: +1. Open the plan price history. +2. Filter by currency/region/date. +3. View the trend and actors. +4. Export CSV/JSON. + +**Postconditions**: +- Chronological immutable records are returned and exported (p95 <= 5s for 100 records). + +#### Effective price preview + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-usecase-price-preview` + +**Actor**: `cpt-cf-bss-pricing-actor-partner` + +**Preconditions**: +- A published plan and a partner catalog-preview read grant (region/currency) exist. + +**Main Flow**: +1. Select the plan in the catalog. +2. The system resolves the base price for the region/currency (fail-closed if no row — no implicit FX). +3. Show the tax-inclusive label, tier summary, and trial length (`displayTrialDays`) if applicable. +4. Show the disclaimer that Tariffs overlays apply at purchase. + +**Postconditions**: +- The partner sees the catalog base list price with an overlay disclaimer. + +**Alternative Flows**: +- **No row for `(currency, region)`**: preview fails closed (no FX fallback). + +## 11. User Interaction and Design + +| **Interface Name** | **Role** | **Steps** | **Mockup Screen** | +|--------------------|----------|-----------|-------------------| +| Plan creation wizard | As a Finance Manager, I want a guided flow to define billing cycle, base price, and add-ons so that plans are complete before publish | 1. Select published SKU
2. Choose billing cycle (one-time / recurring / usage / hybrid) and frequency (monthly…custom)
3. Set PlanTier and optional ordered phases (trial→evergreen)
4. Configure base price and region/currency scope (per-seat: unit price + quantity source)
5. Add tiers, evaluation policy, or add-on rules
6. Review validation summary and submit for approval | — | +| Tiered pricing editor | As a Finance Manager, I want to edit tier bands and model kind so that usage plans rate correctly | 1. Open usage plan price row
2. Add tier rows with bounds and unit prices
3. Select graduated vs volume
4. Set tier aggregation window and billing granularity
5. Inline validation; save draft or submit | — | +| Hybrid plan composer | As a Finance Manager, I want recurring base plus usage on one plan so that hybrid SKUs are modeled once | 1. Select usage-capable SKU
2. Add recurring base price section
3. Add usage price section with tiers or flat rate
4. Validate both sections present
5. Submit for approval | — | +| Billing descriptors panel | As a Finance Manager, I want to set invoice and GL fields on the plan so that Billing renders lines correctly | 1. Open plan publish checklist
2. Complete invoice template, tax category, GL code
3. Set bundle/add-on itemization if applicable
4. Resolve validation gaps | — | +| Add-on configuration | As a Product Manager, I want add-ons with dependency rules so that subscription composition is constrained | 1. Open plan detail
2. Search published add-on SKUs
3. Set required/optional and quantity bounds
4. Optional price override
5. Save and validate compatibility | — | +| Bundle builder | As a Product Manager, I want bundles with rev-share so that marketplace packages are sellable | 1. Select included SKUs and quantities
2. Set min/max bundle constraints
3. Configure rev-share and itemization
4. Set bundle price; review savings vs sum of parts
5. Publish bundle | — | +| Plan approval dashboard | As a Finance Reviewer, I want to approve or reject plan publishes so that material pricing changes are controlled | 1. Open pending approvals queue
2. Review plan diff and threshold flag
3. Approve or reject with reason
4. Track the independent approver when required (submitter + 1 approver) | — | +| Plan retirement and migration | As a Finance Manager, I want to retire a plan and schedule migration so that legacy pricing sunsets safely | 1. View active subscription count
2. Choose immediate or scheduled retirement
3. Select target plan and migration date
4. Review cancelled future price windows AND flagged deltas (contract-locked, entitlement overflow, invalid/missing-required add-ons)
5. If cross-currency/region/frequency: confirm the cancel+new warning (in-place credit forfeited)
6. Confirm migration event | — | +| Price history and export | As an Auditor, I want price history so that I can reconcile contract and billing disputes | 1. Open plan price history
2. Filter by currency/region/date
3. View trend and actors
4. Export CSV/JSON | — | +| Bulk price import | As a Finance Manager, I want bulk price updates so that annual adjustments are efficient | 1. Download CSV template
2. Upload filled file
3. Review validation summary
4. Apply or fix errors and retry | — | +| Effective price preview | As a Partner, I want list price preview so that I can quote customers | 1. Select plan in catalog
2. System resolves base price for region/currency (fail-closed if no row — no implicit FX)
3. Show tax-inclusive label, tier summary, and trial length (`displayTrialDays`) if applicable
4. Disclaimer for Tariffs overlays | — | + +## 12. Acceptance Criteria + +> **As a** Finance Manager or Product Manager **I want** to model subscription plans and price structures in the catalog **so that** Subscriptions can sell them and Rating can charge deterministically. + +### Plan definition and billing cycles + +**1. Create recurring monthly plan** +- **Given** a published SKU without usage metering (or with metering ignored for pure recurring) +- **When** a Finance Manager creates a Plan with `billingCycle=recurring`, frequency `monthly`, and a base Price row +- **Then** the system MUST store the Plan in `draft` (`PlanTier` is optional at draft and REQUIRED at publish — AC #7; it is not an input to this creation step, so creation MUST NOT demand it) +- **And** MUST validate non-negative amount (>= 0; `0` allowed for free/trial tiers), ISO 4217 currency, and active region scope + +**2. Create recurring annual plan** +- **Given** a published SKU and FinanceReviewer role +- **When** a Finance Manager creates a Plan with frequency `annual` and base Price row +- **Then** the system MUST persist annual billing cycle metadata on the Plan +- **And** MUST allow independent price rows per currency/region for the same plan + +**3. Usage-based plan with pricing tiers** +- **Given** a published SKU with `meteringUnit` configured +- **When** a Finance Manager configures tier bands and selects `modelKind` `graduated` or `volume` +- **Then** the system MUST reject overlapping quantity ranges +- **And** MUST persist tier definitions, `modelKind`, `tierAggregationWindow`, and `billingGranularity` + +**4. One-time plan constraints** +- **Given** a Plan with `billingCycle=one-time` +- **When** add-on rules include recurring-only add-on SKUs +- **Then** publish validation MUST fail with a clear business error +- **And** MUST NOT emit `PlanPublished` until resolved + +**5. Availability dates and one-time quantity** +- **Given** a Plan of any billing cycle (purchasability dating is the unified `availableFrom`/`availableTo` pair) +- **When** a Finance Manager sets `availableFrom`/`availableTo` (and, on a one-time Plan, min/max purchase quantity) +- **Then** the system MUST reject `availableFrom` in the past (relative to **UTC** per manifest) for new plans, **except** via an explicit historical-import path that MAY set past dates (governed by AC #65 — restricted role, audited, no downstream charge windows) +- **And** MUST reject `minQty > maxQty` + +**6. Hybrid plan (recurring + usage)** +- **Given** a published SKU with `meteringUnit` +- **When** a Finance Manager defines a hybrid Plan with both a recurring base Price and a usage Price row +- **Then** publish validation MUST pass only when both components exist +- **And** the read model MUST expose both rows under one `planId` for Tariffs hybrid evaluation + +### Plan composition, phases, and descriptors + +**7. Mandatory PlanTier** +- **Given** a Plan in `draft` without `PlanTier` +- **When** publish is requested +- **Then** publication MUST be blocked +- **And** the operator MUST receive a field-level validation message +- **And** publish MUST **also** block when the Plan's `PlanTier` differs from its parent SKU's `PlanTier` unless an explicit, audited override is set (plan-vs-SKU tier consistency) + +**8. Plan phase price schedule (catalog)** +- **Given** a recurring Plan with configured, ordered phases (trial, intro, evergreen) +- **When** the Plan is published +- **Then** the catalog read model MUST map each phase id to applicable price row references and persist phase ordering with each phase's `convertsToPhaseId` successor +- **And** Subscriptions MUST be able to resolve phase→price and trial-to-paid binding without reading draft state +- **And** trial **runtime** mechanics (payment-method requirement, auto-convert vs. expire — enforcing the catalog-published `phaseDurationDays`/`displayTrialDays`) remain owned by Subscriptions Trial Management + +**9. Billing descriptor completeness** +- **Given** a Plan submitted for publish +- **When** invoice line template, tax category, or GL mapping is missing per manifest minimum set +- **Then** publication MUST be blocked +- **And** the validation report MUST list missing descriptor fields + +**10. Bundle itemization mode** +- **Given** a Bundle with multiple included SKUs +- **When** a Product Manager sets `invoiceItemization=aggregate` or `itemize` +- **Then** the system MUST persist the mode on the Bundle +- **And** published snapshots MUST include itemization rule for Billing line generation + +### Pricing tiers and usage policy + +**11. Graduated vs volume distinction** +- **Given** identical numeric tier bands, including a `$0` first band (`fromQty=0`) +- **When** one price row is `modelKind=graduated` and another is `modelKind=volume` +- **Then** both MUST publish successfully +- **And** the read model MUST persist distinct model kinds for Tariffs (no inference at rating time) +- **And** a total quantity falling entirely within the `$0` first band MUST resolve to a `0` charge under both kinds (no implicit minimum) + +**12. Tier band validation** +- **Given** tier bands on a usage price row following the `[fromQty, toQty)` convention +- **When** bands overlap, leave coverage gaps, or `fromQty` is not ascending +- **Then** save/publish MUST fail +- **And** the top band MUST be **open-ended** (`toQty=null`) — a closed top band MUST fail publish (quantity capping is owned by entitlement quotas; per-period fee caps are Tariffs Future scope), so any quantity is always rateable on a tiered row +- **And** SHOULD warn (advisory, non-blocking) when any band's effective unit price exceeds the previous band's (non-volume-discount pattern) + +**13. Tier aggregation window required** +- **Given** a tiered usage price row +- **When** `tierAggregationWindow` is unset at publish +- **Then** publication MUST fail +- **And** the error MUST reference allowed values per Glossary + +**14. Billing granularity required** +- **Given** a usage price row (tiered or flat usage) +- **When** `billingGranularity` is unset at publish +- **Then** publication MUST fail +- **And** the published row MUST be consumable by Tariffs step 3 + +**15. Flat usage unit price** +- **Given** a usage SKU without tier bands +- **When** a Finance Manager sets `modelKind=flat` and unit price +- **Then** publish MUST succeed with single-rate read model +- **And** tier fields MUST remain empty, but `billingGranularity` is still REQUIRED (per AC #14) — flat usage is not exempt from evaluation-policy fields + +### Multi-currency, tax display, and price rows + +**16. Multiple currencies on one plan** +- **Given** an existing Plan with USD price for region US +- **When** a Finance Manager adds EUR price for region EU on the same plan +- **Then** both price rows MUST be stored linked to the same `planId` +- **And** MUST emit `PriceCreated` per row (frozen event name — see §4 Event alignment; no aliases) + +**17. Duplicate scope-key rejection** +- **Given** a Plan with a published price row on a canonical scope key `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` +- **When** a second row is created on the **same** canonical scope key without the supersession workflow +- **Then** the system MUST reject the duplicate +- **And** MUST suggest update of the existing row; rows differing on **any** key axis — e.g. a `trial`-phase price, an `existing_grandfathered` row (AC #25), or the `usage` vs `recurring` component of a hybrid plan — are **distinct**, not duplicates + +**18. Tax-inclusive flag** +- **Given** a price row with `taxInclusive=true` for a region with configured tax rate +- **When** the price is displayed or published +- **Then** the system MUST persist `taxInclusive=true` on the snapshot +- **And** MUST show tax-included labeling in preview UI; tax calculation remains downstream +- **And** because Tax Engine is **post-MVP** (§13 External dependencies), at MVP a `taxInclusive=true` price row MAY be authored but MUST be flagged **not-sellable-GA** until Tax Engine GA — per row / `(currency, region)` market, gating only the tax-inclusive markets of a mixed plan (tax-inclusive validation + `region`→jurisdiction depend on it); MVP sells tax-exclusive + +**19. Tax-inclusive without tax rate** +- **Given** `taxInclusive=true` for a region without tax rate configuration +- **When** publish is requested +- **Then** publication MUST fail or require explicit override per the **tenant tax-display policy** (default fail-closed; see §17.6 Tenant policy objects) +- **And** tax **scheme** determination (VAT/GST/US sales tax) remains owned by Tax Engine — catalog persists only display basis and `taxCategory` + +### Add-ons and bundles + +**20. Add-on dependency rules** +- **Given** a base Plan and published add-on SKUs +- **When** add-on rules are saved with required/optional and min/max/step +- **Then** the system MUST validate SKU compatibility with the base SKU +- **And** MUST reject conflicting add-on pairs before publish + +**21. Required add-on quantity bounds** +- **Given** an add-on marked required +- **When** `maxQty=0` is configured +- **Then** validation MUST fail +- **And** MUST NOT allow save + +**22. Add-on price override reference** +- **Given** an add-on rule with catalog price override (discounted vs standalone) +- **When** the rule is saved +- **Then** the system MUST persist override reference on the plan snapshot +- **And** SHOULD warn (advisory, non-blocking) when the override unit price exceeds the add-on's standalone list price + +**23. Bundle with rev-share** +- **Given** a marketplace Bundle with vendor and platform SKUs +- **When** rev-share percentages are configured (parties = the platform cut + one or more vendor shares) +- **Then** all shares MUST sum to 100% per included vendor SKU, with the platform cut being one **explicit** share (not an implicit remainder) +- **And** the operator MUST nominate a **primary party** at bundle authoring (default = the platform) that absorbs any rounding residual (e.g. 33.33%×3) within a <= 0.01% (1 bp) tolerance; publish MUST **normalize** the absorber's effective share so the published shares sum to exactly 100% (typed values audited, the adjustment recorded); a residual over tolerance MUST fail publish +- **And** rev-share is scoped per `(bundle, vendor SKU)` — the same SKU in multiple bundles carries independent shares; `invoiceItemization=aggregate` MUST still preserve per-SKU rev-share for Marketplace accrual + +### PriceWindow linkage + +**24. Publish requires window coverage** +- **Given** a billable Plan with price rows for `(currency, region)` but no active or scheduled `PriceWindow` +- **When** publish is requested +- **Then** publication MUST fail for production sellability +- **And** MUST direct the operator to schedule a window via the window-scheduling API (owned here since the consolidation) +- **And** for **one-time** plans the two effectivity mechanisms are distinct and both apply: a **`PriceWindow`** governs **price effectivity** (a purchase at `t` MUST have a covering window) while `availableFrom`/`availableTo` govern **purchasability** — when they disagree, purchase requires **both** an active window **and** an open availability interval (AC #5) + +**25. Grandfathering eligibility on window/price** +- **Given** a price row with `priceEligibility=existing_grandfathered` and cutover timestamp +- **When** published +- **Then** the read model MUST expose eligibility metadata for Tariffs step 2 +- **And** new subscriptions after cutover MUST NOT bind to grandfathered row +- **And** the grandfathered row and its successor row are **distinct canonical scope keys** (they differ by `priceEligibility`) and both MAY hold active `PriceWindow`s at the same `t` without violating non-overlap; Tariffs step 2 selects by `priceEligibility` + subscription `activatedAt` + +### Publish validation, approval, and events + +**26. Publish validation fail-closed (aggregate)** +- **Given** a Plan submitted for publication +- **When** validation detects any of: ambiguous meter, overlapping tiers, tier coverage gaps, **unspecified `modelKind` on a tiered row**, a `graduated`/`volume`/`package` kind on a non-usage row, evaluation-policy fields present on a non-usage row, a `package` row with non-positive `packageSize`/negative `packagePrice`/tier bands present, a prepaid grant with unset `expiryPolicy` or an unpublished `creditUnit`, a `promotional` grant carrying price rows or `autoRechargeAllowed`, a grant `applicability` naming an unpublished meter or a non-usage line (or, for a metered `creditUnit`, leaving that unit's meters), missing PlanTier, missing descriptors, invalid hybrid, missing evaluation-policy fields, currency precision exceeding the ISO 4217 minor unit, an unknown `region`, an unknown `brand` on a brand-scoped `PriceOverlay`, an add-on dependency cycle, an uncovered gap between scheduled `PriceWindow` rows, a `customEveryN Days` cycle with a `calendar_month`/`fixed_day` anchor, a `PriceOverlay` adjustment with an ambiguous tax basis, an amount-based `PriceOverlay` adjustment missing a value for a currency its target scope sells, a rev-share reconciliation forcing a negative share, a configuration forcing mixed-currency lines on one invoice, a recurring row missing `billingTiming`, a one-time setup row that is not one-time (has recurrence, `billingTiming`, or tier fields), a `minQtyThreshold` with no declared floor type (or a `usage` floor with no declared fallback), a plan `PlanTier` unequal to its SKU `PlanTier` without an audited override, an `availableFrom`/`availableTo` not covered by an active or scheduled window, a dangling or cyclic `convertsToPhaseId` (or a non-terminal phase without `phaseDurationDays`, or not exactly one terminal phase), a phase with no covering recurring price row for a sold `(currency, region)`, `creditOnDowngrade = true` combined with `prorationBasis = none`, an unresolvable rounding policy (neither row-level nor tenant default), an `allowedChangeTargets` entry that is unpublished or lacks a `comparabilityRank`, an entitlement grant set with an undefined feature/quota/`PlanTier` policy, a `prepaid`-category grant price that is unscoped or does not cover every sold `(currency, region)`, a derived meter with an unpublished constituent or a self-reference, a non-positive custom interval `n`, or a **required** scope key with no covering price row — the *required coverage set* = every canonical scope key that (a) has a scheduled `PriceWindow`, (b) is a `sum_of_parts` bundle component's sell scope, or (c) is an add-on/override target for a scope the base plan sells +- **Then** publication MUST be blocked +- **And** MUST NOT emit `PlanPublished` or warm Rating read models + +**27. Successful publish fan-out** +- **Given** a Plan passing validation and required approvals +- **When** publication completes +- **Then** the system MUST emit `PlanPublished` carrying a **pending** `CatalogVersion` reference (the addressable version is committed by the registry, which MAY batch — see §17.5 CatalogVersion increment contract); the committed `CatalogVersion` and the `pricingSnapshotRef` composite are finalized when the registry emits `CatalogVersionPublished` +- **And** Rating/Tariffs MUST resolve `{skuId, planId, priceId}`, model kind, and evaluation-policy fields from the committed `CatalogVersion` without draft reads + +**28. Two-person rule on material change** +- **Given** a material change — a price change above the configured threshold, **or** a first publish with no prior baseline (always material, per Glossary) +- **When** a Finance Manager submits for publish +- **Then** the system MUST require **one independent approver** before `PlanPublished` (submitter + 1 approver = **two distinct principals**; the submitter MUST NOT approve — AC #70) +- **And** MUST log the submitter and the approver identities and timestamps + +**29. Approval rejection** +- **Given** a pending plan approval +- **When** a Finance Reviewer rejects with reason +- **Then** the Plan MUST return to `draft` +- **And** MUST emit audit event with rejection reason; submitter MUST be notified + +**30. Auto-publish below threshold** +- **Given** a plan change and a tenant approval-threshold policy +- **When** publish is requested +- **Then** the system MUST apply the two-person rule **by default** (see AC #28; auto-publish is the **only** exception), and MAY auto-publish with **no independent approver** **only if** a threshold is explicitly configured **and** the change is below it **and** it is not a first publish (no configured threshold, or a first publish, implies two-person rule always applies) +- **And** in either path the system MUST emit `PlanPublished` and an audit record + +**31. Pricing snapshot on subscription artifacts** +- **Given** an active subscription from a published Plan +- **When** Subscriptions or Rating generates charge artifacts +- **Then** artifacts MUST carry catalog refs and `pricingSnapshotRef` per manifest §4.1 +- **And** posted invoice periods MUST NOT re-query mutable catalog rows + +### Plan lifecycle, update, and migration + +**32. Plan update with active subscribers** +- **Given** a published Plan with active subscriptions +- **When** price or tier configuration changes +- **Then** the system MUST version the Plan and create new Price records (immutable history) +- **And** existing subscriptions MUST continue on frozen snapshot until renewal or migration +- **And** exception: subscriptions bound via `existing_grandfathered` are **live-resolved** by Tariffs against an **immutable** grandfathered row (MUST NOT be superseded; sunset only via `grandfatherUntil` — see §17.5), so their price stays stable and consistent with the frozen-snapshot doctrine + +**33. Plan retirement — block new, preserve existing** +- **Given** a Plan with active subscriptions +- **When** immediate retirement is executed +- **Then** new subscriptions to that `planId` MUST be blocked +- **And** existing subscriptions MUST retain plan snapshots; catalog MUST emit `PlanRetired` + +**34. Scheduled migration to alternative plan** +- **Given** a retiring Plan and published target Plan +- **When** migration is configured with effective date and target `planId` +- **Then** the system MUST emit the migration-scheduled event **`PlanMigrationScheduled`** (frozen event name — see §4 Event alignment; no aliases) for Subscriptions +- **And** Subscriptions MUST create effective-dated `PlanLink` without posted invoice mutation + +**35. Retirement cancels future price windows** +- **Given** a Plan with scheduled future `PriceWindow` rows +- **When** the Plan is retired +- **Then** the system MUST cancel not-yet-active windows and MUST **run the window-cancellation flow, which emits `PriceWindowCancelled` per cancelled window** (frozen manifest event name; produced by this PRD's design since the consolidation; drives the cache-eviction path, <= 2s NFR) — it MUST NOT merely mark windows invalid without the cancellation flow +- **And** a pending or approved-not-yet-effective grandfathering cutover on the plan's keys MUST be **unwound** in the same transaction: the predecessor window's `effectiveTo` restored to its pre-cutover value, the scheduled copy/successor windows cancelled, the unit closed as unwound (a merely `submitted` unit is voided per the standard pin semantics) — otherwise the shortened window would strand in-flight subscribers uncovered at the cutover instant +- **And** retirement of a plan holding a live cutover unit is **always material** (two-person rule) +- **And** MUST warn the operator of cancelled windows — and of any cutover to be unwound — before confirm + +**36. Contract-locked plan** +- **Given** an active contract referencing a Plan revision +- **When** structural plan mutation is attempted +- **Then** the system MUST reject while lock is active +- **And** MUST direct operator to new Plan revision or contract expiry + +**37. Concurrent edit detection** +- **Given** two Finance Managers editing the same Plan +- **When** the second submits with stale version/ETag +- **Then** the system MUST reject with conflict +- **And** MUST require refresh before retry + +### Operator efficiency + +**38. Plan clone** +- **Given** a source Plan (draft or published) +- **When** clone is executed with new name +- **Then** the system MUST create a new `planId` in `draft` with copied configuration and new price ids +- **And** MUST record `clonedFrom` reference; MUST NOT affect source subscriptions + +**39. Bulk price import** +- **Given** a valid CSV with plan id, currency, region, amount, taxInclusive columns +- **When** bulk import is applied +- **Then** **validation** MUST be all-or-nothing **pre-commit** (any invalid row blocks the whole batch with a per-row report) +- **And** at **commit**, the system MUST take optimistic per-row locks; a per-row ETag conflict (concurrent manual edit) MUST fail **only that row** and the batch MUST be reported **partial** (committed rows stand; conflicted rows are listed for retry) — it MUST NOT silently overwrite + +**40. Price history and export** +- **Given** a Plan with price change history +- **When** an Auditor requests history with filters +- **Then** the system MUST return chronological immutable records with actor and effective dates +- **And** MUST support export within p95 <= 5s for 100 records + +### Price preview and access control + +**41. Catalog base price preview** +- **Given** a published Plan and partner context (region, currency) +- **When** preview is requested +- **Then** the system MUST return catalog base price, taxInclusive flag, and tier summary if applicable +- **And** MUST indicate that Contract/PriceOverlays apply at purchase (Tariffs) + +**42. RBAC deny-by-default** +- **Given** a user without CatalogAdmin, FinanceReviewer, ProductManager, or Auditor role +- **When** plan mutate APIs are called +- **Then** access MUST be denied +- **And** attempts MUST be audit-logged +- **And** **read/preview** APIs MUST also be **deny-by-default**: an unlisted-role principal (e.g. a Partner/SI holding none of CatalogAdmin/FinanceReviewer/ProductManager/Auditor) MUST be denied unless it holds an explicit **catalog-preview read grant** (a scoped role/claim); partner preview (AC #41) requires this grant, region/brand-scoped by IdP claims (AC #43) +- **And** the **historical-import (backdating) capability** is a distinct restricted grant (a scoped `CatalogAdmin` capability, NOT a default role) required by AC #65 + +**43. Tenant and brand isolation** +- **Given** two tenants with plans configured +- **When** a user queries or mutates plans +- **Then** the system MUST enforce tenant isolation +- **And** MUST scope brand/region per IdP claims at gateway (authorization/display scope; pricing `region` is decoupled — see AC #68); here `brand` is an **authorization/display** scope, **not** a price-row key (see §2.2 Canonical scope key) + +### Commercial model coverage + +**44. Per-seat (per-unit recurring) plan** +- **Given** a published SKU and a recurring Plan priced per unit (seat/user) +- **When** a Finance Manager sets `modelKind=per_unit`, a unit price, and `quantitySource` (`subscription_seat_count` \| `manual`) +- **Then** publish MUST persist the unit price and quantity source +- **And** the read model MUST let Rating/Tariffs resolve per-period quantity **from the source declared by `quantitySource`** — from Subscriptions when `subscription_seat_count`, or from the fixed value frozen on the row when `manual` — without inferring or metering it (no metering-unit footprint) + +**45. Custom billing frequency** +- **Given** a recurring Plan +- **When** a Finance Manager sets `frequency` to `quarterly`, `semiannual`, or `customEveryN{Days|Months}(n)` +- **Then** the system MUST persist the interval as metadata +- **And** MUST reject a non-positive `n` +- **And** a `customEveryN Months(n)` `subscription_start` anchor beyond the target month's length MUST clamp to the month's last day with the anchor day preserved across periods (Jan 31 → Feb 28 → Mar 31 — no drift; UTC) + +### Proration and provisioning contracts + +**46. Proration input contract (catalog → Subscriptions)** +- **Given** a recurring price row submitted for publish +- **When** publication completes +- **Then** the read model MUST expose `billingAnchorPolicy`, `prorationBasis`, and `creditOnDowngrade` +- **And** these fields MUST be frozen in `pricingSnapshotRef`; proration math remains downstream (rating gear; Subscriptions sets the boundary/mode) +- **And** a mid-cycle change crossing currency, region, or billing frequency MUST be rejected for in-place proration (handled as cancel + new subscription; see §17.6 Proration input contract) + +**47. Entitlement grant set in published read model** +- **Given** a Plan submitted for publish +- **When** validation runs +- **Then** the published read model MUST include the plan's entitlement grant set (feature flags, quotas) or its `PlanTier`-resolved reference for Subscriptions provisioning +- **And** publish MUST fail if a referenced feature, quota, or `PlanTier` policy is undefined in the registry + +### Currency and FX policy + +**48. Missing currency row fails closed (no implicit FX)** +- **Given** a preview or publish for a `(currency, region)` with no price row +- **When** resolution runs +- **Then** the system MUST fail closed and MUST NOT compute or apply FX conversion +- **And** a base-currency fallback MUST occur only when an explicit `currencyFallbackPolicy` is configured (Future scope) + +### Lifecycle safety (contract lock and entitlement overflow) + +**49. Scheduled migration vs contract-lock and entitlement overflow** +- **Given** a scheduled migration to a target Plan +- **When** some source subscriptions are contract-locked, or the target Plan's entitlement limits are below current usage/seat counts +- **Then** the system MUST exclude contract-locked subscriptions from the migration and report them, and MUST NOT break an active contract lock +- **And** the migration config MUST surface entitlement deltas and warn/block when target limits < entitlements in use; runtime enforcement is owned by Subscriptions +- **And** the migration config MUST flag subscribers whose current add-ons become invalid under the target plan's add-on rules (e.g. a now-incompatible add-on), AND subscribers who **lack a required add-on** declared by the target plan, reporting both as blocking deltas alongside entitlement deltas + +### Read-model and publish robustness + +**50. Consumer-side read-model resolution (determinism)** +- **Given** a published Plan with `modelKind=graduated` +- **When** Tariffs or Rating resolves the price input via `pricingSnapshotRef` +- **Then** it MUST receive the exact ordered tier bands, `tierAggregationWindow`, and `billingGranularity` as published +- **And** it MUST NOT read draft state and MUST NOT substitute any default for an absent evaluation-policy field (absence MUST have failed publish) + +**51. Publish fan-out atomicity / compensation** +- **Given** a Plan passing validation and approval +- **When** `PlanPublished` is emitted but read-model warming or a downstream notification fails +- **Then**, once the version commits (`CatalogVersionPublished`), the system MUST either complete warming within the 5s SLO via retry, or mark the publish degraded and emit a `PlanPublishDegraded` signal; the pre-commit batching delay (`PlanPublished` → `CatalogVersionPublished`) is governed by the AC #95 max batching-delay SLO, **not** by `PlanPublishDegraded` +- **And** the system MUST NOT leave a state where charges can resolve against a partially-published plan + +**52. Currency precision per ISO 4217 minor units** +- **Given** a price row in a currency with a non-2 minor unit (e.g. JPY=0, BHD=3) +- **When** an amount is saved with more precision than the currency's ISO 4217 minor unit +- **Then** publish MUST reject it +- **And** a `0`-minor-unit currency MUST reject any fractional amount; a 3-minor-unit currency MUST accept up to 3 decimals + +**53. Billing/ERP descriptor sufficiency** +- **Given** a published billing descriptor set +- **When** Billing/ERP consumes the snapshot to render an invoice line and post to GL +- **Then** the descriptor set MUST contain the agreed minimum field set (invoice template, tax category, `glCode`, itemization rule) sufficient to post **without re-querying mutable catalog rows** +- **And** publish MUST fail if any field in that agreed minimum set is absent (field list fixed with Billing/Payments in Design) + +### Phases and PriceOverlay authoring + +**54. Phase ordering integrity** +- **Given** a Plan with ordered phases referencing each other via `convertsToPhaseId` +- **When** publish runs +- **Then** the system MUST reject any `convertsToPhaseId` that is dangling (targets a non-existent phase) or forms a cycle +- **And** MUST require exactly one terminal phase (no successor, e.g. `evergreen`) +- **And** MUST reject a phase with no covering published recurring price row for a sold `(currency, region)` (an uncovered phase would rate to nothing at conversion) +- **And** usage rows resolve phase-invariantly by default (one usage row covers all phases; an explicit phase-scoped usage row wins for its phase — adopted verbatim by Tariffs) + +**55. PriceOverlay authoring and precedence** +- **Given** a Catalog Admin authoring a `PriceOverlay` row +- **When** scope (partner/orgTier/brand/region/**customerGroup**/global), adjustment type (markup/discount/fixed), and an explicit `precedence` value are set +- **Then** the system MUST persist the row and MUST reject a duplicate `precedence` within the same scope class (deterministic ordering for Tariffs) +- **And** an **amount-based** adjustment MUST carry per-currency values covering every currency its target scope sells (fail-closed at authoring; a later-added currency flags the overlay for remediation while the uncovered market resolves at base price); a percent adjustment is currency-neutral +- **And** precedence/stacking **evaluation** remains owned by Tariffs (authoring/validation only here) +- **And** a `PriceOverlay` adjustment MAY be **effective-dated** via its own **adjustment-level effectivity interval** `[effectiveFrom, effectiveTo)` — **outside** the per-plan `PriceWindow` mechanism, since an adjustment is scope-wide and has no single `planId`/`phase`/`priceEligibility`/`chargeKind`; absent an interval it applies whenever the base row is active. Overlap is validated **per `PriceOverlay` scope + adjustment target** at authoring (not on the canonical price-row key). Base price rows always carry `priceOverlay = base` (see §2.2) + +**56. PriceOverlay references unpublished plan/SKU** +- **Given** a `PriceOverlay` row whose scope references a plan or SKU that is not published +- **When** the row is saved or the list is published +- **Then** the system MUST reject it with a clear business error +- **And** MUST NOT expose the row in the read model + +### Migration robustness and eligibility + +**57. Migration idempotency and cancellation** +- **Given** a scheduled migration +- **When** the migration trigger is re-executed (retry) or the operator cancels before the effective date +- **Then** retry MUST be idempotent (no duplicate `PlanLink` requests for already-processed subscriptions) +- **And** cancellation MUST invalidate the scheduled migration event without affecting already-migrated subscriptions + +**58. Legacy subscription migration without prior snapshot** +- **Given** a legacy subscription with no `pricingSnapshotRef` +- **When** it is migrated or first rated under this system +- **Then** the system MUST synthesize and freeze a snapshot from the published plan state **as of the trigger instant (UTC), frozen at execution** (not config time), recording it as `migrated-origin`: for the `migration` trigger the instant is the **migration effective timestamp**; for the **first-rating** trigger (no prior snapshot, not a migration) it is the **earliest unrated usage timestamp (UTC)** for the subscription — two implementations MUST freeze identical prices for the same subscription +- **And** MUST persist a provenance record containing at minimum: source `planId`/revision, resolved price ids, the snapshot instant (UTC), the trigger (`migration` \| `first-rating`), and the acting principal (exact field shape MAY be finalized in Design; this field list is normative) +- **And** the snapshot MAY reference a backdated price row created via the historical-import path (AC #65) — this is the **only** sanctioned backdated reference path; it MUST NOT silently rate against mutable catalog rows + +**59. `new_subscriptions_only` eligibility binding** +- **Given** a price row with `priceEligibility=new_subscriptions_only` and a cutover timestamp +- **When** published +- **Then** new subscriptions created on/after cutover MUST bind to the row +- **And** existing subscriptions MUST NOT be re-bound to it (they retain their prior snapshot) + +### Cross-PRD contract conformance + +**60. Tariffs tier-boundary conformance** +- **Given** a published tier set under the `[fromQty, toQty)` half-open convention +- **When** Tariffs evaluates a charge at each band boundary value (including the `fromQty=0` `$0` band and the open-ended top band) +- **Then** the charge MUST match a jointly-owned golden conformance fixture exactly (no off-by-one at band edges) +- **And** publish MUST be blocked if the conformance fixture is absent for a newly introduced `modelKind` +- **And** `package` (repeating-block) and `per_unit` (external-quantity) MUST **each** have a joint golden fixture before publish (per §17.2); absent the fixture, plans of that kind MUST NOT publish (two Scope items stay unsellable until Tariffs ships the semantics) + +**61. Proration field-consumption contract** +- **Given** each proration input field (`billingAnchorPolicy`, `prorationBasis`, `creditOnDowngrade`) +- **When** the publish contract is validated +- **Then** each field MUST map to a named consuming requirement in Subscriptions (Proration Logic) **and** in Tariffs, which consumes the **same** `prorationBasis` for PriceWindow-split / plan-change proration and MUST adopt the canonical enum **verbatim** +- **And** a shared golden fixture MUST assert that `calendar_days_30` day-capping and `fixed_day(31)`→February month-end UTC rollover produce identical results on the catalog and Subscriptions sides, **and** a boundary fixture MUST assert that a mid-period **PriceWindow-split** proration produces identical results on the catalog↔Tariffs side + +**62. Read-model monotonicity and staleness bound** +- **Given** an in-flight publish fan-out +- **When** a consumer resolves a price within the 5s warm window +- **Then** it MUST resolve against a single committed `CatalogVersion` and MUST NOT observe a partially-warmed version (the read model is **monotonic per `CatalogVersion`**; a version MUST be ignored until its `CatalogVersionPublished` **and** warm-completion marker — under batching a version bundles many `PlanPublished` events, so there is no per-`PlanPublished` marker) +- **And** a rating run MUST pin **one** `CatalogVersion` for its **entire duration** (determinism); at **pin time** the pinned version MUST NOT **lag the newest completed `CatalogVersion` by more than 5s** (a relative freshness bound, not an absolute age), then it holds for the whole run (no mid-run swap). This reconciles AC #51 and the event-propagation NFR without making long runs non-reproducible + +**63. CatalogVersion increment on publish** +- **Given** a Plan passing validation and approval +- **When** `PlanPublished` completes +- **Then** the plan's content MUST become addressable in a `CatalogVersion`, with the registry as the **sole** incrementer; the registry **MAY batch** multiple approved publishes into **one** discretionary catalog publish (**not** one dedicated version per `PlanPublished`), and `pricingSnapshotRef` MUST pin the exact version containing the plan (a mass repricing MAY coalesce into one/few versions — AC #101) +- **And** publish-contract sign-off MUST remain blocked until the registry owner confirms the increment-trigger taxonomy (per-publish vs batched; see §15 Open Questions) + +### Approval, governance, and access control + +**64. Material-change comparison for multi-currency changes** +- **Given** a price change touching multiple `(currency, region)` rows +- **When** the approval-threshold policy is evaluated +- **Then** each affected row's delta MUST be compared against the per-currency threshold **in its own currency** +- **And** if **any** row exceeds its threshold, the two-person rule MUST apply + +**65. Historical-import (backdating) governance** +- **Given** the historical-import path that permits past `availableFrom`/effective dates (AC #5) +- **When** it is invoked +- **Then** it MUST require a **restricted role** and a **mandatory reason**, and MUST be fully audited (audit-completeness NFR fields) +- **And** every imported row MUST pass the row-shape validation subset (taxonomies, precision, scope-key uniqueness, model shape) — parity with regular authoring +- **And** the import MUST be an **always-material** change: it lands only after an independent second approver confirms it +- **And** it MUST NOT generate or re-open any downstream billable charge window + +**66. Cross-boundary mid-cycle change — operator warning** +- **Given** a mid-cycle change crossing currency, region, or billing frequency (modeled as cancel + new subscription per AC #46) +- **When** the operator previews or configures the change +- **Then** the preview/migration UI MUST warn that in-place proration credit is **forfeited** and the change is modeled as cancel + new subscription +- **And** the operator MUST explicitly confirm before proceeding + +**67. Trial duration published for preview/quoting** +- **Given** a Plan with a `trial` phase declaring an authored trial length +- **When** published +- **Then** the catalog read model MUST publish `displayTrialDays` so preview/quoting can show the trial length (e.g. "14-day trial") +- **And** Subscriptions MUST enforce trial runtime **from this same published value** (single source: catalog authors, Subscriptions enforces) — `displayTrialDays` is not a second runtime authority + +**68. Cross-region authoring authorization** +- **Given** a user whose IdP authorization region claim is `EU` +- **When** the user attempts to author or mutate a price row scoped to pricing `region=US` +- **Then** access MUST be denied unless the user's authz scope explicitly grants the target pricing region (pricing `region` is independent of, and not implied by, the authz region claim) +- **And** the attempt MUST be audit-logged (per AC #42) + +### Discounts and approval governance + +**69. Interim discount reference (conditional day-1 hook)** +- **Given** the Promotions PRD is not GA-ready and typed credit rows are Future scope +- **When** a Plan is published with an optional `discountRef` +- **Then** the catalog MUST validate that `discountRef` resolves to a registered external discount instrument and MUST persist it on the snapshot +- **And** the catalog MUST NOT author, evaluate, or stack the discount (evaluation owned by Tariffs/Promotions); absence of `discountRef` MUST NOT block publish + +**70. Self-approval prohibition (segregation of duties)** +- **Given** a Plan submitted for publish that requires the two-person rule (material change, AC #28) +- **When** an approver acts on the submission +- **Then** the submitter's identity MUST NOT satisfy the required approval slot (the submitter and the independent approver MUST be **distinct principals**) +- **And** any self-approval attempt MUST be rejected and audit-logged with actor and timestamp + +### Validation completeness + +**71. Evaluation-policy fields forbidden on non-usage rows** +- **Given** a `flat` non-usage or `per_unit` price row +- **When** `tierAggregationWindow` or `billingGranularity` is present +- **Then** publish MUST fail with a field-level error +- **And** the aggregate fail-closed validation (AC #26) MUST include this condition + +**72. Region taxonomy validation** +- **Given** a price row with a `region` value +- **When** the row is saved +- **Then** `region` MUST be a member of the tenant's configured region taxonomy +- **And** an unknown region MUST fail validation before publish + +### Grandfathering and concurrency + +**73. Grandfathering time-bound at renewal** +- **Given** a grandfathered price row with an optional `grandfatherUntil` (UTC) +- **When** a bound subscription renews on/after `grandfatherUntil` +- **Then** the published read model MUST signal that the subscription is no longer eligible for the grandfathered row +- **And** Subscriptions MUST re-bind it to the current eligible row at renewal (catalog publishes the bound; Subscriptions executes); a null `grandfatherUntil` means indefinite + +**74. Concurrency precedence between bulk and interactive edits** +- **Given** an in-flight bulk price import holding per-row optimistic locks (AC #39) +- **When** a concurrent interactive plan-level edit (AC #37) targets a row under bulk lock +- **Then** the interactive edit MUST fail with a conflict naming the bulk operation +- **And** the system MUST NOT silently overwrite either change + +### Scope-key and tax validation + +**75. Brand taxonomy validation (brand-scoped `PriceOverlay`)** +- **Given** a **brand-scoped `PriceOverlay`** row with a `brand` value (`brand` is a `PriceOverlay` scope, **not** a price-row axis — see §2.2) +- **When** the row is saved +- **Then** `brand` MUST be a member of the tenant's configured brand taxonomy +- **And** an unknown/invalid brand MUST fail validation before publish (mirrors region validation, AC #72) + +**76. Tax-exclusive without tax category** +- **Given** a price row with `taxInclusive=false` for a region with no configured `taxCategory` +- **When** publish is requested +- **Then** publication MUST warn or fail per the **tenant tax-display policy** (default fail-closed), symmetric with AC #19 +- **And** the behaviour MUST be governed by the **same** tenant tax-display policy object (no separate undefined path) + +### Lifecycle and structural integrity + +**77. Published price rows are never deleted (supersede/retire only)** +- **Given** a **published** price row on the canonical scope key `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` — **whether or not** subscriptions are currently bound to it +- **When** removal/deletion of the row is attempted +- **Then** the system MUST reject the deletion — published rows are **append-only history** (AC #32, #97); there is **no deletion event** to fan out to warmed caches, and quotes, previews, exports, and `PriceOverlay` adjustments may reference the row +- **And** the operator MUST use supersession + grandfathering (or retirement + migration) instead, preserving the frozen snapshot for bound subscriptions; **deletion is available only for `draft` rows that were never published** + +**78. Future-gap coverage across scheduled windows** +- **Given** a plan with two or more scheduled `PriceWindow` rows for one scope key +- **When** publish runs +- **Then** validation MUST reject any uncovered time interval (gap) between the end of one active/scheduled window and the start of the next for billable periods +- **And** MUST direct the operator to close the gap via the Slice 7 publish-time coverage / window-scheduling flow (extends the publish-time coverage check, AC #24) + +**79. Add-on dependency acyclicity** +- **Given** add-on rules with inter-add-on dependencies +- **When** publish runs +- **Then** the system MUST reject any dependency cycle among add-ons (analogous to phase acyclicity, AC #54) +- **And** MUST report the cycle path + +### Currency binding and lifecycle integrity + +**80. Invoice currency binding** +- **Given** a subscription activating against a plan with multiple `(currency, region)` price rows +- **When** the subscription is created +- **Then** it MUST bind to exactly one `(currency, region)` row, and all recurring, usage, and add-on lines for that subscription MUST resolve in the bound currency +- **And** publish/preview MUST reject the **enumerated** configurations that would force mixed-currency lines onto a single invoice: (i) an add-on / override / required-add-on row lacking a row in a currency the base plan publishes; (ii) a `sum_of_parts` bundle whose component rows do not cover every currency the bundle sells; (iii) an `own_price` bundle without a matching-currency component set. (currency selection at activation is owned by Subscriptions; the single-currency-per-invoice invariant is enforced here) + +**81. Clone field semantics** +- **Given** a clone of a source Plan (AC #38) +- **When** the new draft is created +- **Then** `priceEligibility` and `grandfatherUntil` MUST reset to defaults (not copied), and contract locks MUST NOT be copied +- **And** `discountRef` MUST be copied only if it still resolves to a registered instrument (else dropped with an operator notice); the clone MUST record `clonedFrom` and MUST NOT bind to any source subscription + +**82. Upstream SKU retirement coherence** +- **Given** a registry SKU referenced by draft or published plans +- **When** the registry retires/unpublishes that SKU +- **Then** new plan publishes against it MUST be blocked, affected drafts MUST be surfaced to operators, and posted snapshots and active-subscription bindings MUST NOT be mutated +- **And** the catalog MUST emit a signal enabling operators to remediate affected drafts (contract coordinated with the registry) + +### Authoring guardrails and discount GA gate + +**83. Rev-share residual guardrails** +- **Given** a bundle with rev-share where a non-platform party is nominated primary (AC #23) +- **When** the rounding residual is absorbed +- **Then** no party's resolved share may be < 0%, and the residual MUST be absorbed per `(bundle, vendor SKU)` independently within <= 0.01% tolerance +- **And** publish MUST fail if reconciliation would force any negative share + +**84. `customEveryN Days` anchor compatibility** +- **Given** a recurring plan with `frequency=customEveryN Days(n)` (AC #45) +- **When** `billingAnchorPolicy` is `calendar_month` or `fixed_day(d)` +- **Then** publish MUST fail — a `Days`-based custom cycle MUST anchor on `subscription_start` +- **And** `n` MUST NOT exceed the configured custom-interval cap (tied to the size-limit NFR, AC #104) + +**85. `PriceOverlay` adjustment tax basis** +- **Given** a `PriceOverlay` markup/discount/fixed adjustment authored against a base price row (AC #55) +- **When** the base row is `taxInclusive=true` +- **Then** the adjustment row MUST declare the basis it applies to (inclusive vs exclusive) or explicitly delegate the basis to Tariffs +- **And** publish MUST reject a `PriceOverlay` adjustment that leaves the basis ambiguous + +**86. Refund/cancellation credit basis (conditional)** +- **Given** a recurring price row eligible for cancellation credit — **conditional: this AC activates only on the Finance day-1 decision; otherwise the field remains Future scope** +- **When** published +- **Then** the read model MUST expose a `cancellationCreditBasis` — a **closed** enum (`daily` \| `none`) frozen in `pricingSnapshotRef`, with credit execution owned by Billing/Payments (extending the enum requires a versioned contract change, not an open-ended value set) +- **And** absence MUST mean "no cancellation credit" (fail-safe), **not** undefined + +**87. Discount-path availability at sellable-GA** +- **Given** a sellable-GA readiness review +- **When** a Plan is assessed for sale with discounts +- **Then** at least one supported discount path MUST be available (approved Promotions PRD, typed credit row, or `discountRef` in committed scope) +- **And** absent all paths, the Plan MAY publish but MUST be flagged **not-sellable-GA** (a **plan-level** GA flag covering every scope key of the plan — distinct from the per-market tax-inclusive flag of AC #18; per the §13 External dependencies GA gate) +- **And** `discountRef` is **committed to launch scope** (decided 2026-07-04) — the discount path is satisfied at MVP; a full Promotions PRD remains Future + +### Package and prepaid pricing + +**88. Package (block) pricing** +- **Given** a usage price row with `modelKind=package`, `packageSize`, and `packagePrice` +- **When** the plan is published +- **Then** the read model MUST expose `packageSize` and `packagePrice` with no tier-band fields +- **And** Tariffs MUST bill by rounding usage **up** to whole blocks (blocks = ceil(used / `packageSize`), charge = blocks × `packagePrice`); catalog MUST NOT compute the charge +- **And** this repeating-block formula MUST be reconciled in §17.2 and gated on a joint golden fixture (AC #60) — it is **distinct** from Tariffs' Volume Variant B (per-tier flat fee) + +**89. Package pricing validation** +- **Given** a `modelKind=package` price row +- **When** `packageSize <= 0`, `packagePrice < 0`, or any tier-band field is present +- **Then** publish MUST fail with a field-level error +- **And** the aggregate fail-closed validation (AC #26) MUST include these conditions + +**90. Prepaid credit grant definition** +- **Given** a plan declaring a prepaid credit grant (`grantAmount`, `creditUnit`, `category`, `price`, `expiryPolicy`, `autoRechargeAllowed`, `applicability`, `drawdownPriority`) +- **When** the plan is published +- **Then** the grant fields MUST persist and be frozen into `pricingSnapshotRef`, with the resolved `applicability` **materialized** (authored `all_usage` resolves to the plan's usage lines; a metered `creditUnit` bounds it to that unit's meters) — the executor never infers scope (D-43) +- **And** for `category = prepaid` the grant `price` MUST be scoped per `(currency, region)` like price rows; an unscoped grant price MUST fail publish for a plan selling in multiple `(currency, region)`, the grant-price set MUST cover **every** `(currency, region)` the plan publishes sellable rows for (a missing market fails publish), and grant-price changes are subject to the same approval-threshold / material-change policy (AC #28, #64) +- **And** for `category = promotional` the grant is issued free: price rows and `autoRechargeAllowed` MUST be absent (publish fails otherwise); `expiryPolicy = never` warns +- **And** an `applicability` entry that is unpublished or not a usage line of the plan MUST fail publish; `drawdownPriority` is an authored default — the effective cross-grant order is Billing-owned (`drawdownPriority` → `promotional` first → earlier expiry → earlier issuance → `grantId`, D-43) +- **And** the catalog MUST NOT track balance, compute drawdown, or order live balances (owned by Billing/Rating) + +**90a. Included allowance (D-45)** +- **Given** a `usage` row declaring `includedAllowance = { quantity N, rolloverPolicy }` +- **When** the plan is published +- **Then** for `rolloverPolicy = none` publish MUST compile the declaration into the `$0` first band `[0, N)` plus a frozen first-class allowance marker, and the resolved amounts MUST equal the equivalent hand-authored $0-band row exactly (compile-equivalence) +- **And** for `rolloverPolicy = carry(maxPeriods)` publish MUST materialize the per-period promotional grant (free, `applicability` = this row's meter, expiry = the carry horizon) under the D-43 rules; drawdown execution stays Billing-owned +- **And** publish MUST fail on: a non-`usage` row; a row combining an authored `$0` first band with `includedAllowance`; a non-`sum` row (`aggregationFunction ≠ sum`); `quantity ≤ 0` + +**91. Prepaid grant expiry validation** +- **Given** a prepaid credit grant +- **When** `expiryPolicy` is unset or set to `days(N)` with N <= 0 +- **Then** publish MUST fail (no implicit "never expires") +- **And** only `never` and `days(N>0)` are valid values + +**92. Prepaid grant credit-unit integrity** +- **Given** a prepaid credit grant whose `creditUnit` is a `meteringUnit` +- **When** that `meteringUnit` is not published +- **Then** publish MUST fail +- **And** the catalog MUST NOT persist or expose any prepaid balance (balance ledger owned by Billing/Rating) + +### Non-Functional Requirements (Show-Stoppers) + +**93. Plan mutation latency** +- **Given** authorized operators +- **When** plan create or update is submitted +- **Then** end-to-end persistence and validation MUST complete within 2 seconds at p95 + +**94. Price field validation latency** +- **Given** a single price row save +- **When** currency/region/tier validation runs +- **Then** validation MUST complete within 200ms at p95 + +**95. Publish propagation** +- **Given** a committed `CatalogVersionPublished` event (the point at which a batched publish becomes addressable) +- **When** Rating cache warming runs +- **Then** plan definitions MUST be visible within 5 seconds at p95 of `CatalogVersionPublished` +- **And** the registry MUST bound the delay from `PlanPublished` (pending) to `CatalogVersionPublished` (committed) by a stated **max batching-delay SLO** (ratified with the registry — see §15 Open Questions) + +**96. Plan read latency** +- **Given** catalog browse or plan detail read +- **When** served from read model within tenant partition +- **Then** p95 latency MUST be under 100ms + +**97. Audit completeness and tamper-evidence** +- **Given** any plan or price mutation +- **When** the operation completes +- **Then** the system MUST record actor, timestamp, before/after version, and approval trail +- **And** MUST retain price history for **>= 7 years**, with the retention period **tenant/jurisdiction-configurable** +- **And** where multiple jurisdictional minimums apply, retention MUST be the **maximum** applicable minimum per the data row's residency jurisdiction(s) +- **And** audit and price-history records MUST be stored **append-only / tamper-evident** (hash-chained in the transactional store; optionally anchored to external WORM) so prior versions cannot be mutated or deleted within the retention window +- **And** where a jurisdiction imposes a retention *maximum* (storage-limitation regime) that conflicts with the max-minimum rule, Legal MUST resolve the conflict per the row's residency jurisdiction (see §15 Open Questions) + +**98. Event propagation** +- **Given** `PlanCreated` or `PlanUpdated` +- **When** emitted after mutation +- **Then** downstream consumers MUST receive event within 5 seconds at p95 +- **And** events MUST include correlation/idempotency keys per manifest governance +- **And** consumers MUST be able to dedupe by idempotency key (at-least-once delivery assumed; duplicate redelivery MUST be safe) + +**99. Multi-currency scale** +- **Given** a Plan with many currency rows +- **When** stored and read +- **Then** the system MUST support **at least 20 currencies per plan as a guaranteed floor** (tested at 20; not merely a benchmark point) +- **And** the read SLO (p95 < 100ms, AC #96) MUST hold **at the 20-currency floor**, not only at nominal load + +**100. Mutation API idempotency** +- **Given** a plan or price create/update call carrying a client idempotency key +- **When** the same key is retried +- **Then** the system MUST return the original result and MUST NOT create a duplicate draft plan or price row +- **And** the key MUST be honoured for a defined retention window (TTL); a key reused after the TTL MAY be treated as a new request, and the TTL MUST be documented in the API contract (Design) +- **And** an idempotency-key **replay during an active bulk lock** (AC #74) MUST return the original completed result regardless of current lock state (it is a read of a completed op); a **new** key targeting a locked row follows AC #74 + +**101. Mass repricing performance and idempotency** +- **Given** a mass price adjustment over N rows (e.g. an annual increase across many plans) +- **When** executed or re-executed after a partial failure +- **Then** the operation MUST be idempotent (re-run-safe) and MUST emit deduplicated events +- **And** the throughput SLO MUST be back-calculated from the tenant worst-case row count (plans × currencies × regions) against an agreed maintenance window (provisional >= 50 rows/sec; Design MUST confirm against the worst case, not adopt the provisional figure blindly) + +**102. Data residency for price and audit data** +- **Given** a tenant with a jurisdiction-bound residency requirement +- **When** price and audit/history data are stored and replicated +- **Then** the system MUST keep that data within the configured jurisdiction's residency boundary and MUST NOT replicate a row outside **any** of its residency jurisdictions' boundaries +- **And** residency MUST be consistent with the jurisdiction-configurable retention in the audit-completeness NFR (AC #97) + +**103. Read-model availability and disaster recovery** +- **Given** Rating's hard real-time dependency on the published read model +- **When** the read model serves rating/preview traffic +- **Then** it MUST meet a stated availability SLO (target >= 99.9% per tenant partition) and a disaster-recovery **RPO <= 5 min** / **RTO <= 30 min** (provisional defaults; Architecture MUST ratify before Design — no bare placeholders may ship) +- **And** during a read-model outage, charge resolution MUST **NOT resolve from an unavailable or partially-warmed read model** and MUST fail closed; **frozen `pricingSnapshotRef` resolution is unaffected** (rating against an intentionally frozen snapshot is the core mechanism, not "stale" rating), consistent with AC #62 + +**104. Plan and tier size limits** +- **Given** a Plan with many tier bands and/or price rows +- **When** authored or published +- **Then** the system SHOULD enforce configurable soft caps on tier-band count per row and price-row count per plan, emitting a publish warning above the cap (provisional defaults: <= 100 tier bands per row, <= 500 price rows per plan — testable defaults, tenant-configurable; Design may tune), and MUST enforce the custom-interval cap of AC #84 (provisional: `customEveryNDays` <= 366, `customEveryNMonths` <= 24, tenant-configurable) +- **And** the caps MUST protect the read SLO (AC #96) and mass-repricing SLO (AC #101) from pathological plans + +### Commercial-shape and lifecycle completeness (review follow-ups) + +**105. Recurring billing timing (`billingTiming`)** +- **Given** a recurring or hybrid Plan submitted for publish +- **When** validation runs +- **Then** every recurring price row MUST carry `billingTiming` (`in_advance` \| `in_arrears`), frozen in `pricingSnapshotRef`; usage rows are implicitly `in_arrears` +- **And** publish MUST fail if a recurring row omits `billingTiming`; a hybrid plan MAY combine an `in_advance` base row with `in_arrears` usage rows (Billing derives its deferral policy from this — Billing PRD) + +**106. Optional one-time setup/activation charge on recurring/hybrid plans** +- **Given** a recurring or hybrid Plan with an optional one-time setup price row on the same `planId` +- **When** the Plan is published +- **Then** the read model MUST expose the setup row as a **first-class** plan price row (in approvals, `pricingSnapshotRef`, and preview), charged **once per subscription lifetime** (at activation; at entry into the first non-trial phase for trialed plans; never re-charged on plan change/`PlanLink` migration) +- **And** publish MUST validate it as one-time (no recurrence, no `billingTiming`/tier fields) — it MUST NOT require a synthetic add-on SKU + +**107. Purchase gated on an active window (sellability)** +- **Given** a published Plan whose bound canonical scope key has only a **scheduled** (not-yet-active) `PriceWindow` at time `t` +- **When** subscription/purchase creation is attempted at `t` +- **Then** creation MUST be blocked (joint rule with Subscriptions) — a plan MUST NOT be sold before an active window covers the bound scope key, preventing a first rating that resolves no window (fails closed) +- **And** optional plan-level `availableFrom`/`availableTo` (the unified purchasability-dating pair for **all** billing cycles), when set, MUST be validated against window coverage at publish; deferred publish ("publish at T") is out of launch scope + +**108. Plan-change contract published** +- **Given** a Plan that participates in self-service plan changes +- **When** the Plan is published +- **Then** the read model MUST publish `allowedChangeTargets` (**explicit** target `planId`s; rules are Future scope) and a `comparabilityRank` (or an authoritative published `PlanTier` ordering) so Subscriptions can classify upgrade/downgrade/switch and constrain targets +- **And** every edge MUST carry its boundary classification (`in_place` | `cancel_plus_new`) so cross-boundary consequences are disclosed before execution; a retired target makes the edge inert (re-checked at change time) +- **And** absence of `allowedChangeTargets` MUST mean **no self-service change** (fail-safe), not any-to-any; enforcement remains in Subscriptions + +**109. Customer-group segment pricing** +- **Given** an operator-defined customer group (BSS-owned taxonomy) and a `customerGroup`-scoped `PriceOverlay` with an adjustment (`markup`/`discount`/`fixed`) per `(group, region)` +- **When** the plan is published and a payer's effective-dated group membership is resolved via `payerTenantId` +- **Then** the read model MUST expose the `customerGroup` `PriceOverlay` and the group taxonomy; Tariffs resolves the payer's group and applies the overlay (§17.7); the **resolved group** MUST be frozen in `pricingSnapshotRef` +- **And** a membership change is **renewal-aligned by default** (resolved group pinned in the subscription snapshot until renewal; immediate re-resolution is an explicit material change), a group discount/move affecting many payers is a **material change** (two-person rule, AC #28), and all membership changes MUST be audited +- **And** a payer holds **at most one active membership across all groups** — a conflicting enrollment MUST be rejected (409) naming the active membership; a transfer MUST be one atomic audited move (end + start, no gap or overlap instant) +- **And** entirely different **tier structures** per group are **out of launch scope** (use separate plans + group-scoped plan eligibility — Future) + +### Governance and referential-integrity completeness (review follow-ups) + +**110. Pending-approval content pin** +- **Given** a material change submitted for approval (content hash pinned per AC #28 mechanics) +- **When** the subject is mutated while the approval is pending +- **Then** the approval MUST be voided (back to draft; a fresh submit is required) +- **And** an approval decision MUST verify the pinned content hash and reject on mismatch — a reviewer approves exactly what they reviewed +- **And** an approver whose authorization scope does not cover every region/brand the pinned change touches MUST be rejected (403) and the attempt audited + +**111. Bulk approval per-row pin and reuse** +- **Given** a material bulk batch approved with per-row content hashes +- **When** Phase-2 per-row commits conflict and shrink the committed set +- **Then** the committed set MUST be a subset of the approved set +- **And** a retry of conflicted rows with unchanged content MUST reuse the original approval; a changed row MUST require a fresh approval + +**112. Single pending approval unit per scope key** +- **Given** a pending approval unit (supersession or grandfathering cutover) on a canonical scope key +- **When** a second unit is submitted on the same key +- **Then** the submission MUST be rejected with a conflict (409) naming the pending unit +- **And** the rule is symmetric with bulk operations: a bulk/repricing row whose key holds a pending interactive unit fails per-row, and a submitted material batch pins its keys against interactive submits (409 naming the bulk operation) + +**113. Retirement referential guard** +- **Given** a plan referenced as a bundle component or as an add-on price-override target +- **When** retirement is requested +- **Then** the request MUST be rejected with the referencing compositions enumerated; remediation precedes retirement + +**114. PriceOverlay disclosure default** +- **Given** a `PriceOverlay` authored without an explicit `disclosure` value +- **When** it is saved/published +- **Then** `disclosure` MUST default to `restricted` (fail-closed) +- **And** consumer-facing enumeration/preview MUST NOT expose a restricted overlay (or its existence) to out-of-scope payers + +**115. Cross-class tie-break published** +- **Given** `PriceOverlays` of different scope classes with overlapping targets +- **When** the read model is published +- **Then** it MUST expose the normative class-specificity tie-break `customerGroup > partner > orgTier > brand > region > global` +- **And** Tariffs MUST adopt the tie-break verbatim (joint fixture; AC #61 mechanics) + +## 13. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| Catalog registry (Product & SKU) | Published `skuId`, `bundle` SKU type, `meteringUnit` declaration, `PlanTier` value/taxonomy, tax/GL codes, and `CatalogVersion` (sole incrementer); increment taxonomy **Proposed** | `p1` | +| ~~PriceWindow (effective-dating use case)~~ | **Consolidated into this PRD** (§15 answered): window scheduling/activation/events owned by the pricing gear; the legacy UC doc is scenario source material — no external dependency remains | — | +| Tariffs / PLAL | Consumes the read model (model kind, tiers, evaluation policy, reserved rate, derived-meter definition, `customerGroup` overlays); evaluates formulas/overlays/FX; composes `pricingSnapshotRef`; adopts the eight-axis scope key and resolves a grandfathered generation by the pinned price id's `cohort` (ADR-0002) | `p1` | +| Subscriptions | Owns the plan-change boundary/mode + runtime, plan-change classification, trial runtime, entitlement enforcement, `PlanLink` migration, sellability checks from published inputs (proration math = rating gear); proration seam is a GA gate | `p1` | +| Rating | Consumes events + warmed read models; resolves deterministic inputs; owns Usage -> RatedCharge orchestration | `p1` | +| Billing / Payments | Consumes descriptors via `CatalogVersion`; derives deferral policy from `billingTiming`; owns refunds/credits and PSP/ERP posting; minimum descriptor field set TBD | `p1` | +| Tax Engine | Tax-scheme determination + `region` -> jurisdiction mapping; **confirmed post-MVP** (ETA ~8 months) — MVP is tax-exclusive; tax-inclusive plans GA-gated | `p1` | +| Contracts & Agreements | Contract locks (exclude locked subscriptions from migration); negotiated RI-style reservation rates | `p1` | +| Promotions / Coupons | Coupon/discount authoring + evaluation (dedicated PRD **TBD — does not yet exist**); `discountRef` resolves to a registered external instrument | `p2` | +| Marketplace | Consumes bundle rev-share rules for fee accrual | `p2` | +| Prepaid balance ledger + drawdown + auto-recharge | Billing / Rating; **TBD** — prepaid grants can be defined but not sold/consumed until balance execution exists | `p2` | +| BSS Architecture Manifest | §4.1 Catalog, §4.2 Rating, §4.3 Subscriptions, §2.1.3 identities, §4.8 Marketplace | `p1` | + +> **Conformance fixtures before code**: the jointly-owned golden fixtures (tier-boundary per AC #60, proration per AC #61, level-aggregation per D-44 — granule fold, late-sample re-fold, `maxHold` gap) MUST be stood up and version-controlled in a shared repo **before** implementation; publish-contract sign-off is gated on green fixtures. Every GA-gate dependency MUST be tracked on the program board with a named owner and target date. + +## 14. Assumptions + +- NFR provisional targets (read-model availability/DR RPO/RTO, mass-repricing worst-case throughput, plan/tier size caps, idempotency-key TTL) are working assumptions and MUST be ratified before Design lock; no bare placeholders may ship. +- The catalog registry is SoR for Product/SKU/Category/Attribute/`CatalogVersion`, the `bundle` SKU type, the metering-unit declaration, and the `PlanTier` taxonomy; this PRD composes **published** units and **freezes** plan/price/descriptor content into the `CatalogVersion` the registry commits. +- `CatalogVersion` uses a **batched/discretionary** publish model: `PlanPublished` carries a pending ref and `pricingSnapshotRef` pins the committed batch version; the exact increment-trigger taxonomy (incl. the overlay/membership publish units, §17.5) and the max batching-delay SLO value remain to be confirmed with the registry. +- Tax Engine is confirmed **post-MVP**; MVP sells **tax-exclusive** and `taxInclusive=true` **price rows/markets** are authorable but flagged **not-sellable-GA** (per `(currency, region)` market, not the plan as a whole) until Tax Engine GA. +- `discountRef` is committed as the **day-1 discount hook** (referential-integrity only); a full Promotions PRD remains the durable owner (Future). +- Subscriptions owns the change boundary/runtime/`PlanLink`; Tariffs evaluates formulas, overlays, FX, and proration; Billing posts invoices and derives deferral policy from `billingTiming`; the catalog computes **no** monetary charges. +- PriceWindow scheduling, the UTC activation job, and `PriceWindow*` event emission are owned by the pricing gear (Slice 7, D-03 / ADR-0003); the legacy effective-dating price-windows use case is retained only as a scenario source, not a runtime owner. This PRD owns linkage, coverage, and activation. +- Prepaid credit **balance execution** (ledger, drawdown, zero cut-off, auto-recharge) is owned by Billing/Rating; the catalog defines the grant only. + +## 15. Open Questions + +> Decisions already resolved for this PRD are recorded inline (Glossary, §17 appendices, Acceptance Criteria). This table lists only items still requiring an external decision or sign-off. + +| **Question** | **Status / Owner** | **Date Answered** | +|--------------|--------------------|-------------------| +| Day-1 discount: ratify a Promotions PRD (owner + date) **or** commit `discountRef` into scope? | **Answered — `discountRef` committed into launch Scope** (day-1 discount hook; a full Promotions PRD remains the durable owner, Future). Owner: Program + GTM. | 2026-07-04 | +| Tax Engine PRD exists (scheme determination + `region`->jurisdiction mapping)? | **Answered — confirmed but post-MVP** (roadmap P1 right after MVP, ETA ~8 months). MVP is **tax-exclusive**; `taxInclusive=true` is **authorable but a GA gate** (AC #18/#19). Owner: Architecture/Tax. | 2026-07-04 | +| `CatalogVersion` increment trigger taxonomy (per-publish vs **batched/discretionary**; mass-repricing coalescing; **max batching-delay SLO** per AC #95); registry as sole incrementer? | **Answered (model) — batched/discretionary** catalog publish adopted (pending-ref on `PlanPublished`; `pricingSnapshotRef` pins the batch). **Still open with Registry:** exact trigger taxonomy + the max batching-delay SLO **value** (AC #63, #95, #101). Owner: Registry. | 2026-07-04 (model) | +| Upstream SKU retirement/unpublish joint contract while plans reference it? | **Open** — catalog side specified (AC #82); Registry to confirm the joint contract. Owner: Registry. | — | +| Minimum Billing/ERP descriptor field set + PSP/ERP posting contract beyond `glCode` + events? | **Open** — AC #53 requires sufficiency; field list still TBD. Owner: Billing/Payments. | — | +| Cancellation/refund credits required at launch? | **Deferred** — if yes, conditional AC #86 (`cancellationCreditBasis`, fail-safe = no credit) activates and moves to Scope. Owner: Finance + Billing. | — | +| Cross-boundary (currency/region/frequency) cancel+new limitation — written sign-off + customer-facing constraint entry? | **Open** (AC #66). Owners: Subscriptions, Finance, GTM. | — | +| Two-dimensional (seats × usage) single-line pricing needed at launch? | **Open** — if yes it leaves Future scope (workaround = two rows). Owner: Product. | — | +| Freemium: per-row `$0` vs a first-class structural flag at launch? | **Open** — if reporting cannot distinguish freemium from a `$0` promo band, the flag moves forward. Owner: Product + Analytics. | — | +| Provisional NFR values to ratify before Design (no bare placeholders may ship): read-model availability/DR RPO/RTO (AC #103), mass-repricing worst-case throughput (AC #101), plan/tier size caps + custom-interval cap (AC #104/#84), idempotency-key TTL (AC #100)? | **Open**. Owners: Architecture, Ops, Finance. | — | +| Retention "max applicable minimum" vs jurisdictions imposing a retention *maximum* (storage-limitation)? | **Open** — Legal to resolve per residency jurisdiction (AC #97). Owner: Legal. | — | +| Rate-limiting / per-tenant mutation quotas (abuse control)? | **Deferred** — out of this PRD's scope but MUST land as a platform/gateway NFR. Owner: Engineering/Platform. | — | +| Minimum customer notice for enforced migration (business policy)? | **Open** — platform SHOULD support configurable lead time; default 60-90 days. Owner: Finance/GTM. | — | +| SSP / ASC 606 tags on plan publish? | **Deferred** to Future scope / Finance workshop. Owner: Finance. | — | +| Consolidate the effective-dating price-windows use case into a standalone PRD? | **Answered — consolidated INTO this PRD (2026-07-10, D-03)**: the pricing gear owns the window store, state machine, activation job, and `PriceWindow*` emission (frozen manifest names); no standalone PRD. The UC doc is scenario source material; its FX-rate-lock and subscription-impact-preview scenarios are dispositioned out (no FX in catalog; impact preview needs Subscriptions data). Formal Architecture ack pending. Owner: Architecture. | 2026-07-10 | +| Volume **Variant B** (per-tier block fee) — needed at launch or dropped from Tariffs? (F-39) | **Answered — dropped from Tariffs** (not authorable from the catalog; catalog `volume` = Variant A only). Owner: Product + Tariffs. | 2026-07-04 | +| Self-service **reserved-capacity rate** a launch/migration requirement (§17.7)? (F-42) | **Answered — yes, in launch Scope** (predecessor parity). Tariffs sources the reserved rate from the catalog snapshot; negotiated RI-style rates stay in Contracts. Owner: Product + Tariffs + Contracts. | 2026-07-04 | +| Peak / period-end billing — authorable **`aggregationFunction`** (`sum`\|`max`\|`last`\|`unique`) / capacity-peak kind a launch case? (F-40) | **RE-DECIDED — yes, in launch (2026-07-16, D-44)**, superseding the 2026-07-04 "no": the launch product set bills on levels (cloudlet peak-per-hour, storage GB-month). `aggregationFunction ∈ {sum, peak, time_weighted}` + `aggregationGranularity {hour, day}`; granule-fold summed into an **additive** `Q` (Glossary); frozen in `pricingSnapshotRef`; `last`/`unique` stay Future. Rating semantics: rating T-D-17 + joint fixture. Owner: Product + Rating. | 2026-07-16 | +| First-class **`includedAllowance`** (+ `rolloverPolicy`) vs the `$0`-first-band representation at launch? (F-32) | **Answered — no** (2026-07-04). **RE-DECIDED — yes, in launch (2026-07-16, D-45)**: existing-SKU migration needs first-class allowance. Authored `includedAllowance {quantity, rolloverPolicy {none, carry}}`, publish-**compiled** (`none` → $0 band + frozen marker; `carry` → D-43 per-period promotional grant, Billing executes); `sum` rows only; per-seat and level-meter allowance stay Future. Owner: Product + Analytics. | 2026-07-16 | +| Tariffs to add a **partner-facing effective-price preview** (with hierarchy) so the migrated use-case promise has an owner? (F-34) | **Open — registered as a tracked GA gate on the program board** (owner: Tariffs + GTM): restricted segment pricing does not sell **self-service** until F-34 lands; nothing else holds on it (D-33). AC #41 publishes base list price only. | — | +| **Draft/sandbox bill simulation** extension (simulate a draft plan via downstream evaluation in sandbox, keeping the no-charge-computation boundary)? (F-36) | **Open** — Tariffs simulation is published-state only; consumers MUST NOT read draft (AC #50). Owner: Tariffs + Product. | — | +| **Localization** owner for Plan-owned display fields (extend the registry localization mechanism to Plan/phase/descriptor content; Presentation renders)? (F-37) | **Open** — descriptors are single-language today. Owner: Architecture + Presentation. | — | +| **Customer-group** pricing (per-`(group, region)`) a launch/migration requirement? (F-41, reopened as F-88) | **Answered — yes, in launch Scope (MAJOR)** — adjustment-based `customerGroup` `PriceOverlay` axis (resolved via `payerTenantId`; BSS-owned effective-dated audited membership; §17.7 + AC #109). Different per-group tier *structures* stay Future. Owner: Product. | 2026-07-04 | +| Self-service **term / auto-renew** metadata home (optional `termLength`/`autoRenew` on the Plan)? (F-35) | **Answered — no** (not at launch); explicit boundary added (Out of Scope) — term/auto-renew is not a catalog concern at MVP. Owner: Product + Subscriptions + Contracts + Billing. | 2026-07-04 | +| Unify purchasability dating into one field pair, and decide whether **deferred publish** ("publish at T") is a launch need? | **Answered — unify: yes** (one `availableFrom`/`availableTo` pair for **all** billing cycles); **deferred publish: no** (out of launch scope). Owner: Product + Architecture. | 2026-07-04 | +| Document owner + target release? | **Open** — assign a document owner and target-release milestone before Design. Owner: Program/Architecture. | — | + +## 16. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Tax Engine slips beyond its post-MVP ETA | Tax-inclusive rows/markets stay not-sellable-GA; `region`->jurisdiction mapping deferred | MVP sells tax-exclusive; `taxInclusive=true` authorable but GA-gated (AC #18/#19); track Tax Engine GA on the program board | +| `CatalogVersion` increment taxonomy / batching SLO unconfirmed by Registry | `pricingSnapshotRef` determinism and the AC #95 propagation SLO undefined | Batched/discretionary model adopted now (AC #63); block publish-contract sign-off until Registry confirms taxonomy + SLO value | +| Proration / plan-change enum drift across Subscriptions and Tariffs | Wrong credits on up/down-grade; divergent proration | Canonical `prorationBasis` enum owned here and adopted verbatim; shared golden fixtures (AC #61) before code | +| Conformance fixtures (tier-boundary, package, per_unit, proration, reserved, supersession-continuity, level-aggregation) not stood up before implementation | Off-by-one / model-kind mispricing reaches production | Publish blocked for any `modelKind` — or non-`sum` `aggregationFunction` row (D-44) — lacking a joint golden fixture (AC #60); fixtures version-controlled before code | +| Promotions PRD does not yet exist | No coupon authoring/evaluation owner at launch | `discountRef` committed as the day-1 hook (referential-integrity only, AC #69); full Promotions PRD remains the durable owner | +| Prepaid balance execution (ledger/drawdown/auto-recharge) absent | Prepaid grants definable but not sellable/consumable | Catalog defines the grant only (frozen in snapshot); GA-gate the sellable path on Billing/Rating balance execution | +| Read-model availability/DR numbers provisional | Rating hot-path resilience unproven | Fail-closed on read-model outage (AC #103); Architecture ratifies availability/RPO/RTO before Design | +| Upstream SKU retirement joint contract open | Draft/published plans may reference a retired SKU | Catalog side specified (AC #82); confirm the joint remediation contract with the Registry | + +## 17. Reference Materials + +| **Material** | **Link / path** | **Comments** | +|--------------|-----------------|--------------| +| BSS Architecture Manifest | `docs/bss/manifest/vz-arch-manifest-bss-only.md` | §4.1 Catalog; §4.2 Rating; §4.3 Subscriptions | +| Product Catalog & Marketplace PRD | `docs/bss/prd/PRD-product-catalog-marketplace-202601120119/PRD-product-catalog-marketplace-202601120119.md` | Parent catalog; SKU lifecycle | +| Product & SKU Management PRD (**products** gear) | `gears/bss/products/docs/PRD.md` (vendored 2026-07-16 from upstream PR #4177) | Catalog registry (SoR for Product/SKU/Category/Attribute/`CatalogVersion`, `bundle` SKU type, metering-unit declaration, `PlanTier` taxonomy) this PRD builds on | +| Tariffs — Commercial Pricing Logic PRD | `docs/bss/prd/PRD-tariffs-pricing-logic-202604011200/PRD-tariffs-pricing-logic-202604011200.md` | Formulas, hierarchy, `pricingSnapshotRef`; promotions/FX boundary | +| Rating Engine PRD | `docs/bss/prd/PRD-rating-engine-202604031200/PRD-rating-engine-202604031200.md` | Usage -> billable items (draft) | +| Subscriptions & Entitlements PRD | `docs/bss/prd/PRD-subscriptions-entitlements-202601120119/PRD-subscriptions-entitlements-202601120119.md` | `PlanLink`, migration execution | +| Contracts & Agreements PRD | `docs/bss/prd/PRD-contracts-agreements-202601120119/PRD-contracts-agreements-202601120119.md` | Contract locks | +| Metering & Pricing Module PRD | `docs/bss/prd/PRD-metering-pricing-module-202601120119/PRD-metering-pricing-module-202601120119.md` | Usage collection | +| Billing Ledger & Balances PRD | `docs/bss/prd/PRD-billing-ledger-balances-202604041200/PRD-billing-ledger-balances-202604041200.md` | Posted invoice immutability | +| Plan & Price Modeling use case | `docs/bss/prd/PRD-product-catalog-marketplace-202601120119/UC-plan-price-modeling-202601121200.md` | Scenario reference; superseded | +| Effective Dating & Price Windows use case | `docs/bss/prd/PRD-product-catalog-marketplace-202601120119/UC-effective-dating-price-windows-202601121200.md` | Superseded/absorbed (consolidated here); scenario source material | +| Project glossary | `docs/project-glossary.md` | Canonical terms | +| Trace chain | `AGENTS.md` (repository root) | Manifest -> PRD -> ADR -> Design -> Stories | + +### 17.1 Supported Billing Cycles and Price Structure Kinds (catalog) + +**Supported billing cycles** + +| **Billing cycle** | **Intent** | **Catalog requirements** | +|-------------------|------------|---------------------------| +| **One-time** | Single purchase | One-time base price; optional quantity min/max; optional availability window; MUST NOT attach recurring-only add-ons | +| **Recurring** | Subscription fee (`monthly`, `quarterly`, `semiannual`, `annual`, or `customEveryN{Days\|Months}(n)`) | Base price row(s) per currency/region; links to `PriceWindow`; supports plan phases and `per_unit` (per-seat) pricing; `billingTiming` (`in_advance`\|`in_arrears`) REQUIRED at publish; MAY declare an optional one-time setup row; `frequency` and any custom interval `n` persisted as metadata (publish MUST reject non-positive `n`) | +| **Usage-based** | Charge by metered consumption | Requires parent SKU `meteringUnit`; tier bands or flat unit price; MUST set `billingGranularity` on **all** usage rows (flat or tiered); MUST set `tierAggregationWindow` **when tiered** | +| **Hybrid** | Base fee + variable usage | Same `planId` MUST declare both recurring and usage price components; publish validation MUST reject hybrid without both parts; MAY additionally declare an optional one-time setup/activation row | + +**Supported price structure kinds (catalog flags)** + +| **Kind** | **When used** | **Catalog MUST persist** | +|----------|---------------|---------------------------| +| **Flat** | Fixed unit or period price | `modelKind=flat`; single amount or unit price | +| **Per-unit (per-seat)** | Recurring unit price x external quantity (seats/users) | `modelKind=per_unit`; unit price + `quantitySource` (`subscription_seat_count` \| `manual`); quantity resolved by Subscriptions/operator, **never metered here** | +| **Graduated** | Marginal tier pricing | `modelKind=graduated`; ordered non-overlapping bands | +| **Volume** | Single rate on total quantity | `modelKind=volume`; ordered non-overlapping bands | +| **Package (block)** | Usage billed in whole blocks (e.g. 100 units for $80; 150 used -> 2 blocks -> $160) | `modelKind=package`; `packageSize` (> 0) + `packagePrice` (>= 0); tier-band fields absent; round-up math in Tariffs | +| **Tiered (unspecified)** | — | MUST NOT publish; operator MUST choose graduated or volume | + +Mathematical formulas belong in Tariffs Design; catalog MUST NOT compute charges. + +### 17.2 Model Kind / Tariffs Formula Mapping (conformance) + +The catalog `modelKind` enum and the Tariffs formula matrix MUST reconcile one-to-one; publish of any `modelKind` lacking a **joint golden conformance fixture** MUST be blocked (AC #60). This table is the **single** kind-to-formula source of truth — Tariffs and this PRD MUST NOT diverge from it: + +| **Catalog `modelKind`** | **Tariffs formula** | **Status / joint fixture** | +|-------------------------|---------------------|-----------------------------| +| `flat` | Flat | Exists | +| `graduated` | Tiered (graduated) | Exists (AC #60 boundary fixture) | +| `volume` | Volume — **Variant A** (single rate on total `Q` within `tierAggregationWindow`) | Exists; catalog `volume` maps to **Variant A only** | +| `package` | **Repeating-block** (`blocks = ceil(used / packageSize)`, charge `= blocks x packagePrice`) | **Present in Tariffs**; distinct from Volume Variant B (stair-step per-tier flat fee); joint golden fixture + Tariffs AC pending (AC #60/#88) | +| `per_unit` (per-seat) | **External-quantity** (`unitPrice x quantity` from `quantitySource`) | **Present in Tariffs**; joint golden fixture + Tariffs AC pending (AC #60) | +| `usage` row + `reservedRate`/`reservationFlavor` (reservation variant — **not** a distinct `modelKind`) | **Reserved-rate + on-demand** (reserved rate on matched/allocated quantity, on-demand rate on the remainder; `capacity` flavor bills the allocation regardless of usage) | **Evaluation exists** in Tariffs (step 6); the change needed is **sourcing** — Tariffs MUST take the **self-service** reserved rate from the catalog row (snapshot-frozen) — Tariffs change + joint fixture (AC #60) | +| `usage` row + `aggregationFunction ∈ {peak, time_weighted}` (level variant — **not** a distinct `modelKind`; D-44) | **Granule fold** (`Q` = Σ per-`aggregationGranularity` folds: `peak` = max gauge sample per granule; `time_weighted` = step-integral with bounded `hold_last`), then the row's band/flat math over the additive `Q` (rating T-D-17) | **New in launch (2026-07-16)** — rating `fr-level-aggregation` + AC 5d; joint golden fixture required (AC #60: granule fold, late-sample re-fold, `maxHold` gap); publish of a non-`sum` row blocked without it | + +Tariffs' **Volume Variant B** (per-tier block fee) is **dropped** (decided 2026-07-04) — it was not authorable here (catalog tier bands carry unit prices only, no per-band flat-fee field); catalog `volume` maps to **Variant A** only. + +### 17.3 Plan Composition Rules (normative) + +| **Rule** | **Statement** | +|----------|---------------| +| Parent SKU | Plan MUST reference a **published** SKU; draft SKU MUST block plan publish | +| PlanTier | Every Plan MUST declare `PlanTier` before publish (optional at draft); the taxonomy and SKU-level value are owned by the registry. Publish MUST validate that the Plan's `PlanTier` **equals its parent SKU's `PlanTier`** unless an **explicit, audited override** is declared (default: equal) — no silent divergence (AC #7) | +| Meter injective | Each usage plan **revision** MUST map exactly one `meteringUnit` (per-row injectivity: one priced line per `(meter, dimensionKey)`); ambiguous mapping MUST fail publish. **Multi-meter offerings** are modeled either as a **derived (composite) meter** (one output unit) or as **separate single-meter SKUs composed via bundle/add-ons** (both in launch Scope) | +| Add-on compatibility | Add-on SKUs MUST be published and compatible with base SKU; conflicts MUST fail publish | +| Bundle components | All `includedSkuIds` MUST be published; rev-share MUST sum to 100% per vendor SKU when set | +| Bundle price basis | A Bundle MUST declare its price basis: `sum_of_parts` or `own_price`; the basis and any explicit price MUST be persisted and frozen. For `sum_of_parts` the bundle MUST reference the specific **component `planId`s** (not bare `skuId`s) whose rows are summed, and publish MUST validate that **every** referenced component has a **covering published price row in each `(currency, region)` the bundle sells in** (and matching `frequency` for recurring components). A missing or ambiguous component row MUST fail publish. Rev-share and itemization are independent of the basis | +| Billing descriptors | Publish MUST include complete billing descriptor set per manifest §4.1 | +| Price window coverage | For billable usage at time `t`, an active `PriceWindow` MUST exist for the resolved **canonical scope key** (resolved on the **base** `priceOverlay`) or Tariffs step 2 MUST fail (no silent fallback). Because `priceEligibility` and `chargeKind` are part of the key, a grandfathered row and its successor, and the `recurring`/`usage`/`one_time_setup` components of a hybrid plan, are **distinct keys** that MAY each hold an active window at the same `t` | +| Hybrid completeness | Hybrid plans MUST include at least one recurring and one usage price row | +| One-time setup charge | Recurring and hybrid plans MAY declare an **optional one-time setup/activation price row** on the same `planId`, charged **once per subscription lifetime** (at activation; at trial conversion for trialed plans; never re-charged on plan change/migration) and frozen in `pricingSnapshotRef`. It is a **first-class plan price row** — **not** a synthetic add-on SKU. Publish MUST validate it as one-time (no recurrence, no `billingTiming`/tier fields) | +| Sellability gate | A subscription/purchase MUST NOT be created while **either** no active (not merely *scheduled*) `PriceWindow` covers the bound canonical scope key at `t`, **or** the plan is not yet addressable in a **committed `CatalogVersion`** — a joint rule with Subscriptions. Plans of **any billing cycle** MAY declare optional plan-level `availableFrom`/`availableTo` (validated against window coverage at publish); **deferred publish** is out of launch scope | + +### 17.4 Price Validation Rules (catalog) + +| **Rule** | **Validation** | +|----------|----------------| +| Amount sign | Price amount MUST be **>= 0**. A `0` amount is valid for free tiers, `trial`/`intro` phases, and the first graduated band. Negative amounts MUST be rejected (typed credit/discount rows are Future scope). | +| Currency | MUST be valid ISO 4217 | +| Currency coverage (no implicit FX) | Catalog MUST NOT perform FX conversion. If no price row exists for a requested `(currency, region)`, preview/publish MUST fail closed; no base-currency fallback unless an explicit `currencyFallbackPolicy` is configured (Future). FX is owned by Tariffs/PLAL. | +| Precision | Amount precision MUST follow the **ISO 4217 minor-unit** for the row's currency (0 for JPY/KRW, 2 default, 3 for BHD/KWD/OMR); publish MUST reject amounts with more precision than the currency's minor unit. A flat 2-decimal cap MUST NOT be assumed. | +| Rounding policy | Every published price row MUST resolve a rounding policy — the row-level reference or the tenant default; if neither exists, publish MUST fail (no implicit rounding) | +| Duplicate scope | MUST NOT allow a duplicate price row on the **canonical scope key** without the supersession workflow. Rows differing only by `priceOverlay`, `phase`, `priceEligibility`, `chargeKind`, or `cohort` (e.g. the `recurring` and `usage` components of a hybrid plan, or two grandfathered generations) are **distinct**, not duplicates. `cohort ≠ none` on a non-grandfathered row MUST fail publish | +| Tier order | Tier bands MUST have ascending `fromQty`; ranges MUST NOT overlap | +| Tier contiguity | Tier bands MUST be contiguous (no coverage gaps) under the `[fromQty, toQty)` convention; the top band MUST be **open-ended** (`toQty=null`) — a closed top band MUST fail publish (D-17): quantity capping is owned by entitlement quotas (Subscriptions enforces), per-period fee caps are Tariffs Future; any quantity is always rateable | +| Per-unit quantity source | `modelKind=per_unit` rows MUST persist a `quantitySource` (`subscription_seat_count` \| `manual`); catalog MUST NOT infer or compute the quantity | +| Evaluation-policy placement | `tierAggregationWindow` and `billingGranularity` are **usage-row only**; on `flat` non-usage and `per_unit` rows they MUST be **absent** — presence MUST fail publish | +| Region taxonomy | A price row's `region` MUST be a member of the tenant's configured region taxonomy; an unknown/invalid region MUST fail validation before publish | +| Brand taxonomy | `brand` is a `PriceOverlay` scope value, **not** a price-row field. A **brand-scoped `PriceOverlay`**'s `brand` MUST be a member of the tenant's configured brand taxonomy; an unknown/invalid brand MUST fail validation before publish (AC #75) | +| Tax basis completeness | `taxInclusive=true` without region tax readiness **and** `taxInclusive=false` in a region with no configured `taxCategory` are both governed by the tenant tax-display policy (warn/fail; default fail-closed); readiness = the tenant-declared per-`(tenant, region)` config (Tax Engine-verified post-GA) | +| Min-quantity floor | A price row's `minQtyThreshold` MUST declare its **floor type** — `purchase` (Subscriptions rejects orders below the floor) or `usage` (Tariffs/Rating treats usage below the floor as ineligible and fails closed, **not** silently zero-rated; the `usage` floor declares its fallback on the row — launch: `exception`, the rating exception path). Publish MUST reject a `minQtyThreshold` with no declared floor type or a `usage` floor with no declared fallback, and MUST warn if it falls inside a non-zero-priced band | +| Package pricing | `modelKind=package` MUST persist `packageSize` > 0 and `packagePrice` >= 0; tier-band fields MUST be absent; publish MUST reject otherwise | +| Prepaid grant | If a plan declares a prepaid credit grant, `expiryPolicy` MUST be set (`never` or `days(N>0)`) and `creditUnit` MUST reference a currency or a **published** `meteringUnit`; a `promotional` grant MUST carry no price rows and no `autoRechargeAllowed`; `applicability` MUST resolve to published usage lines of the plan (materialized at publish; a metered `creditUnit` bounds it to that unit — D-43); catalog MUST NOT persist any balance | +| Tax-inclusive | If `taxInclusive=true`, region tax readiness (tenant-declared rate-present marker; Tax Engine-verified post-GA) MUST exist or publish MUST warn/fail per the **tenant tax-display policy** | +| Required add-on | Required add-on MUST have `maxQty >= 1` | + +### 17.5 Price-Change Mechanisms and CatalogVersion Increment Contract + +**Price-change mechanisms (versioning, windows, supersession)** — four mechanisms describe price change over time; they are **distinct and composable**, not alternatives: + +| **Mechanism** | **What it does** | **Artifact** | **Event** | +|---------------|------------------|--------------|-----------| +| Plan **versioning** | Captures a structural/price change as a new immutable revision; prior rows retained as history | New `Price` row(s) + new plan revision | `PlanUpdated` / `PriceCreated` | +| **Supersession** | Replaces the active row for one **canonical scope key** with a newer one | New `Price` row marked superseding; prior row closed | `PriceUpdated` | +| **`PriceWindow`** | Schedules **when** a versioned/superseded row is effective | `PriceWindow` linkage + schedule (store, state machine, and activation owned by the pricing gear — Slice 7, D-03) | `PriceWindowScheduled` / `Activated` / `Cancelled` / `Expired` | +| **Grandfathering cutover** | Atomically **closes the current `all_subscriptions` window by shortening its `effectiveTo` to the cutover** (active windows are **not** cancelled) and creates (a) an `existing_grandfathered` **copy** — a new `cohort` **generation** (repeatable: each cutover creates one; prior generations untouched) — for pre-cutover subscriptions and (b) the `all_subscriptions` successor, as **one approval unit** so no coverage gap opens. A cutover MAY span **multiple scope keys** of the plan at one instant as one approval unit (per-key generations) | Shorten current window `effectiveTo` + schedule `existing_grandfathered` copy + schedule successor (one atomic unit) | `PriceCreated` x2 (grandfathered copy + successor) + `PriceWindowScheduled` x2 + `PriceWindowExpired` at cutover; `PriceWindowCancelled` only for *not-yet-active* windows of the old key | + +Normative relationship: **supersession is versioning scoped to one canonical scope key** — it MUST create a new immutable `Price` row (never mutate in place) and MUST open/close the corresponding `PriceWindow` rather than overlap it. Supersession operates **within one `priceEligibility` class and one `chargeKind`**. An `existing_grandfathered` row is **immutable in price** — it MUST NOT be superseded; the **only** permitted mutation is **setting or tightening `grandfatherUntil`** (never loosening, never the price), which is a **material change** (AC #28). No mechanism may produce overlapping active `PriceWindow` rows for **one** canonical scope key (manifest invariant, as extended in §2.2). + +**CatalogVersion increment contract (with registry)** — `CatalogVersion` is owned by the registry; this PRD **freezes** plan/price/descriptor content into it but does not re-define it: + +| **Change class** | **New `CatalogVersion`?** | **Incrementer** | +|------------------|---------------------------|-----------------| +| Price-only edit (amount/window on existing plan) | Yes — content MUST become addressable in a `CatalogVersion` (MAY be **batched**) | Registry, on catalog publish request | +| Structural edit (model kind, tiers, descriptors, composition) | Yes — addressable in a `CatalogVersion` (MAY be batched) | Registry, on catalog publish request | +| `PriceOverlay` / customer-group membership change | Yes — each committed mutation is a **publish unit through the engine** (validation → pending ref → warm); consumer visibility is version-pinned exactly like plan content, and the registry's batching coalesces chatty membership traffic | Registry, on catalog publish request | +| Draft-only edits (no publish) | No | — | + +On **every** `PlanPublished`, this PRD MUST request that the plan's content become addressable in a `CatalogVersion`; the registry is the **sole** incrementer and **MAY batch** multiple approved publishes into **one** discretionary catalog publish. `PlanPublished` carries a **pending** version reference; the committed `CatalogVersion` is emitted as `CatalogVersionPublished`, and `pricingSnapshotRef` MUST pin that committed version (AC #27, #63). The exact increment-trigger taxonomy and the **max batching-delay SLO** from `PlanPublished` to `CatalogVersionPublished` are confirmed with the registry owner (§15). + +### 17.6 Consumer Contracts Detail + +**Proration input contract (catalog -> Subscriptions / rating)** — this PRD owns **publishing the inputs**; Subscriptions owns the change boundary/mode and mid-cycle runtime, the rating gear the proration math, Billing the credit/adjustment artifacts. Each recurring price row MUST expose the following in the read model, frozen in `pricingSnapshotRef`: + +| **Field** | **Requirement** | +|-----------|-----------------| +| `billingAnchorPolicy` | When the billing period anchors (`calendar_month`, `subscription_start`, `fixed_day(d)`); MUST be set on recurring rows; month-end/UTC handling per Glossary | +| `prorationBasis` | `calendar_days_actual` \| `calendar_days_30` \| `by_second` \| `whole_unit` \| `none` (canonical enum per Glossary, adopted verbatim by Tariffs); how partial periods are apportioned | +| `creditOnDowngrade` | Whether a mid-cycle downgrade is eligible for catalog-sanctioned credit (Subscriptions applies it). The governing value on a downgrade is the **source** row's flag, read from the subscription's **frozen snapshot** (never the target row, never the live catalog). `creditOnDowngrade = true` combined with `prorationBasis = none` is contradictory and MUST fail publish | + +> **Cross-boundary mid-cycle changes (launch scope)**: Mid-cycle plan changes that **cross currency, region, or billing frequency** are **NOT supported at launch** — the proration input contract publishes **no** cross-currency/cross-frequency credit basis. Such a change MUST be handled as **cancel + new subscription**. Same-currency, same-frequency up/down-grades within one `(currency, region)` are in scope. This launch limitation requires **written sign-off from Subscriptions + Finance + GTM** (Open Questions; operator warning is AC #66). + +**Entitlement grant set (catalog -> Subscriptions)** — the Plan MUST publish its entitlement grant set (feature flags, quotas) or its `PlanTier`-resolved reference into the read model; publish MUST fail if a referenced feature, quota, or `PlanTier` policy is undefined. This PRD does **not** define entitlement semantics: + +| **Catalog concept** | **Published as (grant set)** | **Consumed by Subscriptions as** | +|---------------------|------------------------------|----------------------------------| +| `Feature` (capability id, PlanTier-driven) | `featureFlag: bool` entry | `Entitlement` (feature access) | +| Quota (limit id + value, PlanTier-driven) | `quotaKey: value` entry | `Entitlement` (usage/resource quota) | + +**Plan-change contract (catalog -> Subscriptions)** — Self-service plan changes need two catalog facts this PRD MUST publish; enforcement stays in Subscriptions: + +| **Field** | **Requirement** | +|-----------|-----------------| +| `allowedChangeTargets` | The target `planId`s a subscription MAY move to — an explicit list **or** a rule. Absence MUST mean **no self-service change** (fail-safe), **not** any-to-any | +| `comparabilityRank` | An integer rank classifying a change as **upgrade** (higher), **downgrade** (lower), or **switch** (equal). `PlanTier` alone is **not** an ordering unless published as authoritative; otherwise `comparabilityRank` is REQUIRED for any plan that participates in self-service change | + +**Tenant policy objects (catalog governance)** — both have fail-safe defaults: + +| **Policy object** | **Purpose** | **Safe default** | +|-------------------|-------------|------------------| +| Approval threshold policy | Sets the **material-change** threshold above which the two-person rule applies. Materiality MUST be an absolute amount or a percentage delta (per currency). For a multi-currency change, each affected row's delta is compared in its **own** currency and the rule trips if **any** row exceeds its threshold (AC #64). Mutating the policy is itself **always material** (independent second approver required — D-10). | If unset, the **two-person rule applies** (fail-safe); the system MUST NOT auto-publish without an explicit threshold | +| Tax-display policy | Decides warn vs. fail when `taxInclusive=true` lacks a region tax rate **and** the symmetric `taxInclusive=false` row in a region with no configured `taxCategory`; governs only the **display basis** | **Fail closed for ALL tenants**; tax **scheme** determination is owned by Tax Engine | + +**Rating compatibility contract (normative)** + +| **Requirement** | **Statement** | +|-----------------|---------------| +| Stable identifiers | Published plans MUST expose `planId`, `priceId`, `skuId` on all downstream artifacts | +| Snapshot completeness | Publish MUST stamp identifiers sufficient for manifest `pricingSnapshotRef` | +| Model kind | Rows MUST persist `flat` \| `per_unit` \| `graduated` \| `volume` \| `package` — no implicit default at rating time; `per_unit` rows MUST also persist `quantitySource`; `package` rows MUST persist `packageSize`/`packagePrice`; `graduated`/`volume`/`package` are valid on **usage rows only** (D-18) | +| Evaluation policy | Usage rows MUST persist `billingGranularity` at publish; tiered usage rows MUST additionally persist `tierAggregationWindow` | +| Billing timing | Recurring rows MUST persist `billingTiming` at publish; usage rows are implicitly `in_arrears`; absence on a recurring row MUST fail publish | +| Meter mapping | Exactly one `meteringUnit` per usage plan revision; ambiguous config MUST fail publish. A **derived (composite) meter** satisfies this as **one output unit** | +| Derived meter (when used) | If a price row uses a derived meter, publish MUST persist constituent units, formula-as-data, and output unit, and MUST fail if any constituent `meteringUnit` is unpublished or the formula self-references; Tariffs evaluates the formula | +| Events | MUST emit `PlanCreated`, `PlanUpdated`, `PlanPublished`, `PlanRetired`; ordered per `(tenantId, aggregateId)` where manifest applies | +| No charge calculation | Catalog MUST NOT compute monetary charges; Tariffs/Rating consume frozen inputs | +| Descriptor completeness | Publish MUST NOT proceed without billing descriptor set | + +### 17.7 Advanced Pricing Primitives Detail + +**Derived (composite) meter (catalog primitive)** — one price row priced from a **formula across multiple published metering units** (canonical case: VM priced as `vCPU` + `RAM` on one line). This PRD owns the **definition primitive**; Tariffs owns the **evaluation**: + +| **Catalog MUST persist** | **Requirement** | +|--------------------------|-----------------| +| Constituent units | >= 2 **published** `meteringUnit` ids (declared by the registry); each MUST be published before the composite can publish | +| Formula expression | The combination rule **as data** (operands + operator/weights), not executable code; catalog stores it, Tariffs evaluates it | +| Output unit | A single declared **derived unit** the price row rates as one line | +| Determinism | The composite definition MUST be **frozen in `pricingSnapshotRef`** so rating is reproducible | +| No computation here | Catalog MUST NOT compute the formula result; it only persists and publishes the definition for Tariffs | + +**Prepaid credit grant (catalog primitive)** — this PRD owns the **definition**; balance ledger, drawdown, zero cut-off, and auto-recharge are owned by Billing/Rating (External-dependency GA gate): + +| **Catalog MUST persist** | **Requirement** | +|--------------------------|-----------------| +| `grantAmount` | Units granted (> 0), expressed in `creditUnit` | +| `creditUnit` | A currency (ISO 4217) **or** a **published** `meteringUnit`; an unpublished unit MUST fail publish | +| `price` per `(currency, region)` | (`category = prepaid` only) Purchase price of the grant (>= 0), authored **per `(currency, region)`**. The grant is a **plan-attached primitive**, **not** a `Price` row on the canonical scope key — it carries **no** `chargeKind`. A single unscoped price MUST fail publish for a plan selling in multiple `(currency, region)`. Grant-price changes flow through the **same** material-change policy | +| `expiryPolicy` | `never` or `days(N)` with N > 0; MUST be set explicitly (no implicit "never") | +| `autoRechargeAllowed` | Whether auto-recharge MAY be offered (execution owned by Billing); `category = prepaid` only — a recharge is a purchase | +| `category` | `prepaid` (default — purchased at the grant price) **or** `promotional` (issued **free** — price rows MUST be absent; `expiryPolicy = never` warns). Frozen in the snapshot (D-43) | +| `applicability` | The usage lines the credit may offset at drawdown: `all_usage` (default) or a set of **published** `meteringUnit` ids that are usage lines of the plan (never `one_time_setup` or recurring rows — launch rule; a metered `creditUnit` bounds the set to that unit's meters). Publish **materializes** the resolved set into the snapshot — the executor never infers scope (D-43) | +| `drawdownPriority` | Optional int ≥ 0 (lower draws first) — an authored **default rank**. The **effective** order across an account's grants is **Billing-owned**: `drawdownPriority` → `promotional` before `prepaid` → earlier expiry → earlier issuance → `grantId` (deterministic total order, D-43) | +| Determinism | The grant definition MUST be **frozen in `pricingSnapshotRef`**; catalog MUST NOT track balance, compute drawdown, or order live balances | + +**Reserved-capacity price (catalog primitive)** — reservation is modeled as **attributes on the single `usage` price row**, **not** a second row, so it does not price the same `(meter, dimensionKey)` twice: + +| **Catalog MUST persist (on the usage row)** | **Requirement** | +|---------------------------------------------|-----------------| +| `reservedRate` | Committed unit price (>= 0, row currency) for the reserved/allocated quantity, carried **alongside** the row's on-demand unit price/tiers | +| `reservationFlavor` | `consumption` (matched usage at `reservedRate`, remainder on-demand) **or** `capacity` (allocated quantity billed at `reservedRate` regardless of usage — `capacityCharge`); aligned field-for-field with Tariffs `reservationMatch` | +| Evaluation policy | The row **is** a usage row: `billingGranularity` is REQUIRED, and `tierAggregationWindow` is REQUIRED **only when tiered** — the "forbidden on non-usage rows" rule does **not** apply | +| Quantity source | The reserved/allocated quantity is supplied at runtime (OSS/Contracts entitlement/inventory); catalog MUST NOT meter or compute it | +| Determinism & fixture | Frozen in `pricingSnapshotRef`; reservation is a **Tariffs evaluation variant** of the usage row and MUST carry a joint golden fixture before publish (AC #60; §17.2). Catalog MUST NOT track allocation or compute the charge | + +**Boundary**: **negotiated RI-style** reservation rates are carried by Contracts (per-account overlay); **self-service** reservation rates are catalog list pricing (this primitive). Self-service reservation is **in launch Scope**. Tariffs reservation evaluation exists (step 6) and MUST source the **self-service** reserved rate from the catalog snapshot. + +**Customer-group pricing (segment overlay)** — modeled as a **`customerGroup` `PriceOverlay` scope**, **not** a new price-row axis: + +| **Aspect** | **Requirement** | +|------------|-----------------| +| Mechanism | A **`customerGroup`-scoped `PriceOverlay`** overlay carries an **adjustment** (`markup`/`discount`/`fixed`) per **group x region**, resolved via the payer's group membership on **`payerTenantId`** at `t` (Tariffs evaluates). Entirely different **tier structures** per group are **out of launch scope** (use separate plans — Future) | +| Group taxonomy | A **BSS-owned governed taxonomy** (like `region`/`brand`); group values validated against it at authoring | +| Membership | An **effective-dated, audited BSS record** on the payer's commercial profile (resolved via `payerTenantId`); **at most one active membership per payer across all groups** — a transfer is one atomic audited **move** (end + start), so resolution is unique by construction. AMS supplies tenant identity only; the group is a **BSS commercial projection** and MUST NOT change tenant topology | +| Determinism | The **resolved group** MUST be frozen in `pricingSnapshotRef` | +| Membership change | A price-changing operation: **renewal-aligned by default** (pinned in the subscription snapshot until renewal, then re-resolves); an **immediate** re-resolution is allowed as an explicit **material change**. A group discount/move affecting many payers is a **material change** -> two-person rule (AC #28); all membership changes MUST be audited | + +Enforcement (overlay evaluation, precedence stacking) is owned by Tariffs; this PRD owns the `customerGroup` `PriceOverlay` **authoring/validation**, the group **taxonomy**, and publishing the **membership** record into the read model. + +### 17.8 Future Scope + +| **Capability** | **Priority** | **Status** | **Notes** | +|----------------|--------------|------------|-----------| +| Rule-based `allowedChangeTargets` (read-time fail-safe resolution + `partially_resolvable` marker) | `p2` | Follow-on | Launch = explicit lists only (D-23); a rule's targets exist only at read time, so publish-time guarantees need the designed read-time semantics first | +| Multi-group membership (concurrent customer-group memberships + winner/stacking rule) | `p2` | Follow-on | Launch holds **one active membership per payer** (D-09); revisit together with Promotions discount-stacking if a real need appears | +| Tiered per-seat pricing (`graduated`/`volume` bands over seat count on recurring rows) | `p2` | Follow-on | Requires extending `quantitySource` semantics to banded kinds (D-18); until then seat pricing is single-rate `per_unit` | +| Closed top tier band (author-acknowledged fail-closed maximum) | `p3` | Follow-on | Forbidden at launch (D-17 — top band always open; capping = quotas / per-period caps); reintroduce with an explicit author-acknowledged marker only if a real SKU needs "price undefined above X" | +| Plan comparison (side-by-side) | `p2` | Follow-on | Operator/partner decision support | +| Committed-usage / drawdown flags on plan | `p2` | Cross-PRD | Contracts + Tariffs; catalog may expose reference fields | +| Minimum fee / cap per period on plan | `p2` | Follow-on | Rating boundary already reserved: Tariffs sets amount/basis/attachment and emits `PeriodFloorCapObligation` (contract/**plan** ref anticipated); **Billing executes** after step 9 (rating `fr-period-floor-cap-obligation`, §6.11/§17.2). Deferred part = the catalog authoring field: plan-level floor/cap per `(currency, region)`, frozen in snapshot. Distinct from committed-usage pools (Contracts SoR, rating T-D-14) — MUST NOT be conflated (rating §6.2) | +| Two-dimensional pricing (seats x usage on one plan) | `p2` | Follow-on | Combines per-seat recurring (now in Scope) with a usage meter on the same line; multiple-meter math in Tariffs. **Decision gate** (owner Product): if any launch SKU needs single-line seats x usage it re-scopes out of Future (workaround = two rows) | +| Derived (composite) meter pricing (e.g. VM = vCPU + RAM) | `p2` | Follow-on | **Primitive defined** (§17.7); composes published units from the registry; unblocks Tariffs composite-meter (Tariffs Follow-on) | +| Dimensional pricing (event properties) | `p2` | Deferred | OSS/Rating dimension contract | +| Authorable `aggregationFunction` (`sum` \| `peak` \| `time_weighted`) / level-based billing | `p1` | **Decided → Scope (2026-07-16, D-44)** | Re-scoped into **launch** (supersedes 2026-07-04 "not at launch"): granule-fold (`aggregationGranularity {hour, day}`) summed into additive `Q`, frozen in `pricingSnapshotRef`; joint Rating fixture required (rating T-D-17); `last`/`unique` stay Future; no co-occurrence with composite meters at launch | +| Self-service reserved-capacity rate (reserved/on-demand pair) | — | **Decided -> Scope (2026-07-04)** | Now in **launch Scope** (§17.7); Tariffs sources the reserved rate from the catalog snapshot; negotiated RI-style rates stay in Contracts | +| Percentage-of-base pricing model kind | `p2` | Follow-on | Tariffs Future scope | +| Structural free-tier / freemium flag (beyond per-row $0) | `p2` | Follow-on | Per-row `$0` is **now publishable**; a first-class freemium plan flag remains Future. **Decision gate** (owner Product + Analytics): if reporting cannot distinguish a freemium plan from a `$0` promo band, the flag re-scopes into launch | +| First-class `includedAllowance` (+ `rolloverPolicy`) on usage rows | `p1` | **Decided → Scope (2026-07-16, D-45)** | Supersedes the 2026-07-04 keep-$0-band: authored first-class, publish-compiled (`none` → $0 band + marker; `carry` → D-43 grant); rollover **execution** stays downstream (Billing); `sum` rows only; per-seat / level-meter allowance = named Future gates | +| Self-service term / auto-renew metadata on Plan | `p2` | Cross-PRD | **Decided: not at launch (2026-07-04)** — explicit boundary (Out of Scope). If later needed, add optional `termLength`/`autoRenew` frozen in snapshot | +| Customer-group **different-tier-structure** per group | `p2` | Cross-PRD | **Decided (2026-07-04, F-88): the adjustment-based segment overlay is now in launch Scope** (§17.7). What stays Future: a group needing an **entirely different tier structure** — that requires separate plans + group-scoped plan eligibility | +| Typed credit/discount price rows (negative amounts) | `p2` | Follow-on | Negative amounts allowed only on explicitly typed credit rows; today rejected | +| Interim catalog `discountRef` reference field | — | **Decided -> Scope (2026-07-04)** | Committed as the **day-1 discount hook** (referential-integrity only — no authoring/evaluation here). A full Promotions PRD remains the durable owner | +| Minimal in-catalog list-discount price row | `p2` | Conditional | Last-resort fallback if both Promotions PRD and `discountRef` are insufficient at launch: a typed list-discount row (referential-integrity only, no stacking/evaluation here). Decision tied to the day-1 discount program gate | +| Explicit `currencyFallbackPolicy` (FX fallback) | `p3` | Deferred | Default is fail-closed (no implicit FX); fallback requires a configured policy | +| Catalog `refundable` / `creditPolicy` per price row | `p3` | Cross-PRD | Refund execution owned by Billing/Payments; catalog flag only if Finance requires it. **Decision gate** (owner Finance + Billing): if cancellation credits are required at launch, the per-row `cancellationCreditBasis` (conditional AC #86, fail-safe = no credit) re-scopes into launch | +| Marketplace listing eligibility rules per plan | `p3` | Follow-on | Tied to manifest §4.8 | + +--- + +*Child artifacts: ADR(s) for canonical-scope-key and snapshot-versioning strategy; DESIGN for the catalog read model, publish/validation pipeline, and Tariffs / Subscriptions / Registry / Billing integration contracts.* + + + + diff --git a/gears/bss/pricing/docs/STRIPE-GAP-ANALYSIS.md b/gears/bss/pricing/docs/STRIPE-GAP-ANALYSIS.md new file mode 100644 index 000000000..4c5441c5b --- /dev/null +++ b/gears/bss/pricing/docs/STRIPE-GAP-ANALYSIS.md @@ -0,0 +1,261 @@ + + + +# Pricing — Stripe Billing Gap Analysis + + + +- [1. Purpose & method](#1-purpose--method) +- [2. Scope boundary — what Stripe puts on the customer that is NOT ours](#2-scope-boundary--what-stripe-puts-on-the-customer-that-is-not-ours) +- [3. Coverage map — Stripe pricing model vs what we have](#3-coverage-map--stripe-pricing-model-vs-what-we-have) +- [4. Already deferred (§17.8) with Stripe equivalents](#4-already-deferred-178-with-stripe-equivalents) +- [5. Gaps worth acting on](#5-gaps-worth-acting-on) + - [G-1. Coupons / promotion codes (Promotions gear absent)](#g-1-coupons--promotion-codes-promotions-gear-absent) + - [G-2. Prepaid grant is thinner than a Stripe credit grant](#g-2-prepaid-grant-is-thinner-than-a-stripe-credit-grant) + - [G-3. Minimum commitment / committed spend (monetary true-up)](#g-3-minimum-commitment--committed-spend-monetary-true-up) + - [G-4. Grant / discount / tax application order (joint contract)](#g-4-grant--discount--tax-application-order-joint-contract) +- [6. Recommendation summary](#6-recommendation-summary) + + + +## 1. Purpose & method + +A comparison of our Plan & Price Modeling design against **Stripe Billing** (the +`docs.stripe.com/billing/customer` surface plus the products/prices, subscriptions, credit +grants, and coupons models it links to), to answer: *what pricing models / schemas are we +missing?* + +Method: each Stripe concept is bucketed as **HAVE** (already modelled), **DEFERRED** +(consciously in our Future-scope registry, [PRD.md §17.8](./PRD.md) (summary list at DESIGN.md L504)), **OTHER +GEAR** (correctly not a pricing-catalog concern), or **GAP** (worth a decision). Only the +GAP items (§5) are proposed work; everything else is recorded so the boundary is explicit. + +Sources checked: [PRD.md](./PRD.md), [DESIGN.md](./DESIGN.md), all 12 slice designs under +[design/](./design/), [DECISIONS.md](./DECISIONS.md); plus the rating-gear docs for the +evaluation-side boundary — [rating PRD](../../rating/docs/PRD.md) (§17.1 step order, §17.2 +coupon contract stub) and [rating design/06-coupons.md](../../rating/docs/design/06-coupons.md). + +## 2. Scope boundary — what Stripe puts on the customer that is NOT ours + +The Stripe `/billing/customer` page is mostly about the **customer object**, which in our +gear decomposition lives outside the pricing catalog. These are correctly **OTHER GEAR** — +listed here so the boundary is on the record, not to be pulled in: + +| Stripe customer feature | Owner in our architecture | +|---|---| +| Customer balance / invoice credit balance (ad-hoc credit/debit adjustments) | Billing / Subscriptions | +| Tax IDs (store & render on invoice) | Billing / CRM (tax display basis is ours; the ID registry is not) | +| Invoice settings — numbering schemes, custom fields, footer, receipts | Billing | +| Default payment method | Payments | +| Smart Retries / dunning (automatic collection) | Billing / Subscriptions | +| Customer portal | Subscriptions / self-service UI | +| Pending invoice items / one-off invoices | Billing | +| Preferred locales, shipping address | CRM | +| Currency lock per customer | our invariant exists as **single-currency-per-invoice binding** ([04-currency-tax.md](./design/04-currency-tax.md)) | + +Also correctly OTHER GEAR (evaluation / runtime, not catalog structure): graduated-vs-volume +**math**, override/stacking evaluation, FX steps, coupon application (fully designed: [rating design/06-coupons.md](../../rating/docs/design/06-coupons.md)) → **Tariffs/PLAL**; +subscription state machine, proration **math**, trial **runtime**, entitlement +**enforcement**, `PlanLink` execution → **Subscriptions**; balance ledger, drawdown, zero +cut-off, auto-recharge **execution** → **Billing/Rating**; negotiated per-account terms → +**Contracts**; bundle fee **accrual** → **Marketplace**. + +## 3. Coverage map — Stripe pricing model vs what we have + +**HAVE** — modelled today: + +| Stripe pricing concept | Where we model it | +|---|---| +| Products & Prices | `Plan` / `Price` ([01-foundation.md](./design/01-foundation.md)) | +| Pricing models: per-unit, tiered (graduated + volume), package | explicit `modelKind`, `[fromQty,toQty)` tier bands, `package` ([03-price-structure.md](./design/03-price-structure.md)) | +| Usage-based / metered / meters | meters + meter injectivity, `billingGranularity`, `tierAggregationWindow` ([02](./design/02-plan-definition.md)/[03](./design/03-price-structure.md)) | +| Multi-currency prices (per-currency amounts) | per-`(currency, region)` price rows ([04-currency-tax.md](./design/04-currency-tax.md)) | +| Setup fee + recurring + one-time | `chargeKind = one_time_setup` first-class row ([02-plan-definition.md](./design/02-plan-definition.md)) | +| Billing cycles / custom frequency | billing cycles, custom frequency ([02-plan-definition.md](./design/02-plan-definition.md)) | +| Subscription schedules / phases (trial → intro → evergreen) | phases + `convertsToPhaseId` + `phaseDurationDays` + `displayTrialDays` ([02-plan-definition.md](./design/02-plan-definition.md)) | +| Features / entitlements | entitlement grant set, phase-scoped (D-41) ([06-consumer-contracts.md](./design/06-consumer-contracts.md)) | +| Tax (inclusive/exclusive, per region), automatic tax basis | tax-display basis + `not_sellable_ga` gate ([04-currency-tax.md](./design/04-currency-tax.md)) | +| Proration | proration input contract, canonical `prorationBasis` ([06-consumer-contracts.md](./design/06-consumer-contracts.md)) | +| Add-ons | add-on rules ([02-plan-definition.md](./design/02-plan-definition.md)) | +| Reserved / committed-rate (RI-style) | reserved-capacity attributes on the usage row ([10-advanced-primitives.md](./design/10-advanced-primitives.md)) | +| Package / transform-quantity | `package` model kind ([03-price-structure.md](./design/03-price-structure.md)) | +| Composite metering (e.g. VM = vCPU + RAM) | derived (composite) meter formula-as-data ([10-advanced-primitives.md](./design/10-advanced-primitives.md)) | +| Segment / partner / brand price books | `PriceOverlay` (adjustment overlays, precedence) ([09-price-overlays.md](./design/09-price-overlays.md)) | +| Bundles / marketplace composition | bundle basis + rev-share + itemization ([08-bundles.md](./design/08-bundles.md)) | +| Coupon **application** (order, stacking, FX split, snapshot pinning) | rating step-7 evaluator ([rating design/06-coupons.md](../../rating/docs/design/06-coupons.md); [rating PRD](../../rating/docs/PRD.md) §17.1 step 7, §17.2) — authoring/lifecycle is G-1 | + +## 4. Already deferred (§17.8) with Stripe equivalents + +These are **DEFERRED** — consciously recorded in [PRD.md §17.8](./PRD.md) (summary list at DESIGN.md L504). Not +gaps; captured here only to map them to their Stripe analogue so we don't "rediscover" them. + +| Deferred item (§17.8) | Stripe analogue | Note | +|---|---|---| +| `includedAllowance` / `rolloverPolicy` | included usage in a price + credit rollover | Common SaaS shape ("500 units included, unused roll over"); deferred | +| authorable `aggregationFunction` / peak model kind | Stripe meter aggregation (`sum` \| `last` \| `max`) | We fix aggregation downstream today; not author-selectable | +| two-dimensional (seats × usage) single-line pricing | Stripe: two prices on one item | Deferred; today = two rows | +| structural freemium flag | Stripe free price / $0 tier | Expressible today as a `$0` row/tier; no first-class flag | +| `currencyFallbackPolicy` (FX fallback) | Stripe multi-currency / presentment | We are deliberately **fail-closed, no implicit FX** ([PRD.md](./PRD.md) §L524) | +| typed credit/discount (negative-amount) rows | Stripe negative invoice items / credits | Deferred; discounts go through the `discountRef` hook instead | +| per-row `refundable` / `creditPolicy` | Stripe refunds / credit notes | Billing-side; deferred here | +| self-service term / auto-renew metadata | Stripe subscription settings | Subscriptions-side; deferred here | +| per-group different-tier structures | Stripe: separate price per segment | Committed to adjustment-only overlays (F-88); different structure = separate plan | +| plan-level minimum fee / cap per period; committed-usage / drawdown flags on plan | Stripe committed spend / spending minimums on subscriptions | §17.8 rows the original pass missed (it read the shorter DESIGN.md L504 summary). Floor machinery already reserved rating-side (`PeriodFloorCapObligation`, Billing executes); catalog authoring field = the deferred part. Negotiated committed spend = Contracts SoR pools (rating T-D-14) — see G-3 | + +## 5. Gaps worth acting on + +Ranked. Each is a real delta against Stripe that is **not** already deferred in §17.8 and is +either a catalog-side concern or a missing/unpinned **cross-gear contract**. + +### G-1. Coupons / promotion codes (Promotions gear absent) + +**State today — two sides; only one is missing.** + +*Application side (rating gear) — designed, not a gap.* Coupons are the **step-7 evaluator** +in the compiled §17.1 chain ([rating design/06-coupons.md](../../rating/docs/design/06-coupons.md)). +The frozen `CouponSnapshot` contract ([rating PRD](../../rating/docs/PRD.md) §17.2, the +Tariffs-side stub) already carries the structure a coupon needs: `adjustmentType` +(`percent | fixed_amount`), `value`, `settlementCurrency` (`price | billing`, with the +compiled FX split), `applyScope` (`usage | recurring | line_total` + hybrid split-back), +`applyPerTierBand`, stacking (`exclusive_best | ordered_stack`), validity, applicability +filters, redemption eligibility. Reproducibility is solved on that side too: applied ids + +stacking policy are the Tariffs-written **S1 segment of `pricingSnapshotRef`**, and replay +uses the pinned snapshot (T-D-04) — never live campaign state. Catalog-side `discountRef` +stays a referential-integrity hook **by design** ([PRD.md §L412](./PRD.md): neither coupon +authoring nor evaluation is a catalog concern). + +*Authoring/lifecycle side — the actual gap.* The **Promotions gear does not exist** (no PRD): +coupon creation, campaigns, customer-facing promotion codes, redemption limits/counting, and +fraud controls have no owner. This is a **known, tracked open**, not a discovery — rating PRD +§15/§16 + risk register ("align field names with §17.2 before production coupon rating"); +pricing PRD §15 ("dedicated PRD TBD") + risk register. + +**Stripe model** (two first-class objects) — kept as the field checklist for the future +Promotions PRD, mapped against the §17.2 stub: +- **Coupon**: `percent_off | amount_off` (per-currency) → `adjustmentType` + `value` ✓; + `applies_to.products` → applicability filters ✓; `max_redemptions` / `redeem_by` → + validity + redemption eligibility (the counting itself is Promotions lifecycle) ✓; + **`duration`** (`once | repeating(duration_in_months=N) | forever`) → **no explicit §17.2 + counterpart** — the one real field-shape delta: "−20% for the first 3 billing cycles after + redemption" must be provably expressible in the snapshot's validity/eligibility shape, or + needs an explicit field. +- **Promotion code**: a customer-facing code wrapping a coupon (`first_time_transaction`, + `minimum_amount` + currency, `expires_at`, per-customer restriction) → purely + Promotions-side authoring; correctly absent from both catalog and rating. + +**What actually matters**: (1) end-to-end, no coupon can exist until Promotions stands up — +but its consumption contract is already written and fail-closed (§17.2), so the Promotions +PRD starts from a fixed target, not a blank page; (2) the Stripe `duration` shape belongs on +the already-open rating §15 Promotions-alignment agenda, next to the equal-benefit tie-break +and mixed-settlement opens. + +**Options**: +- (a) Stand up the **Promotions PRD/gear** (the durable owner) — the target; this checklist + plus the §17.2 stub are its inputs. +- (b) Upgrade `discountRef` to a typed descriptor the catalog freezes — **rejected**: coupons + flow Promotions → frozen snapshot → rating step 7, bypassing the catalog; a typed catalog + descriptor would open a second authoring path across the settled boundary + ([PRD.md §L412](./PRD.md)) and duplicate the §17.2 schema with guaranteed drift. +- (c) Status quo until Promotions lands — the acceptable interim, precisely because the + evaluation side is snapshot-shaped and fail-closed; the interim work is contract alignment + (§15), not catalog schema. + +**Recommendation**: (a) when prioritized; meanwhile add Stripe `duration` and the promo-code +wrapper fields to the rating §15 alignment agenda. Do **not** type `discountRef`. + +### G-2. Prepaid grant is thinner than a Stripe credit grant + +**State today**: the prepaid grant ([10-advanced-primitives.md L174–182](./design/10-advanced-primitives.md)) +carries `grantAmount`, `creditUnit` (currency **or** published `meteringUnit`), `expiryPolicy`, +`autoRechargeAllowed`, and a per-`(currency, region)` `price`. Balance/drawdown are correctly +GA-gated to Billing/Rating. + +**Stripe credit grant** additionally has three **definitional** fields (i.e. authoring +metadata, **not** "execution" that we can defer to Billing): + +| Stripe field | We have? | Why it's a catalog concern | +|---|---|---| +| **`applicability_config`** (which metered prices/meters the grant is spendable on) | ✗ | Can't express "this wallet applies to egress but not compute". This is *structure*, authored at definition time — not balance. | +| **`category`** (`prepaid` \| `promotional`) | ✗ (always paid — `price` required) | A **free promotional** grant is unrepresentable; our grant always has a purchase price. | +| **`priority`** (drawdown order across multiple grants) | ✗ | Stripe puts it on the grant object; a customer can hold multiple grants (Stripe caps at 100). Arguably catalog-authored default; possibly Billing-owned. | + +Stripe also fixes ineligibility (credits never apply to licensed prices, one-time/setup rows, +one-off invoices) — a rule set our applicability scoping would need to state. + +**Recommendation**: add `applicability` (scope set over meters/prices) and `category` to the +grant definition; decide `priority` ownership (catalog default vs Billing). Small, additive +schema change to `pricing_plan.prepaid_grant` + `GrantValidator`. + +**Actioned — D-43 (2026-07-14, flagged for veto)**: `category` (`prepaid | promotional`), a +**materialized** usage-line `applicability`, and `drawdownPriority` as an authored default +added to the grant definition ([DECISIONS.md](./DECISIONS.md) D-43); the **effective** +cross-grant order is Billing-owned via a normative tie-break chain (`drawdownPriority` → +`promotional` first → earlier expiry → earlier issuance → `grantId`). Rating-side check +passed: the wallet grant is disjoint from step-6 `commitmentPools[]` (rating SEAMS M8), so +this stays a catalog+Billing concern; where drawdown sits relative to tax remains G-4. + +### G-3. Minimum commitment / committed spend (monetary true-up) + +**State today — resolved by inspection; the ownership question is already answered.** The +original pass worked from the DESIGN.md L504 summary list (which omitted the relevant rows) +and never opened the rating docs. The monetary constructs exist, have owners, and the rating +PRD **forbids conflating them** (hybrid rule, [rating PRD](../../rating/docs/PRD.md) §6.2); +the only thing we author catalog-side today is the quantity floor (`minQtyThreshold`), which +is unrelated: + +- **Negotiated committed spend with monetary true-up** ("pay at least \$X/month, true up the + shortfall") — owned end-to-end: commitment pools are **Contracts SoR** + (`commitmentPools[]`, evaluated at step 6), each with a frozen + `poolType ∈ {prepaid_drawdown, committed_rate}` (rating **T-D-14**), and the spend-basis + shortfall is a **normative formula** — `max(0, committedSpend − inCommitBilledAmount)` — + emitted as a structured `TrueUpObligation` for Billing + ([rating design/05](../../rating/docs/design/05-commitments-reservations.md) §4.5). This + **is** Stripe's committed spend on subscriptions; the amount is a Contracts term by + design, not a catalog field. +- **Minimum invoice fee per period (period floor)** — the evaluation boundary is already + reserved: Tariffs sets amount, comparison basis, and attachment scope and emits + `PeriodFloorCapObligation` (amount, basis, period, **contract/plan ref**); **Billing + executes** `max(total, floor)` after step 9 ([rating PRD](../../rating/docs/PRD.md) + `fr-period-floor-cap-obligation`). The **catalog authoring field** is a conscious + deferral — [PRD.md](./PRD.md) §17.8 "Minimum fee / cap per period on plan" (`p2`, + Follow-on) — i.e. §4 territory, which the original pass misfiled as a gap. + +**Resolution — no new decision to record**: ownership is split and settled — Contracts for +negotiated commitments; the plan-level floor is deferred with its machinery reserved (the +obligation already anticipates a **plan** ref). When a self-service plan needs "\$X/month +minimum", the §17.8 row activates as an additive plan-level floor amount per +`(currency, region)`, frozen in the snapshot, feeding the existing obligation — no new +evaluation machinery. Actioned here: the stale §17.8 note ("Tariffs Future scope") updated +to cite the designed rating boundary; the item added to §4 with its Stripe analogue; the +DESIGN.md L504 summary list extended with the two omitted money-shaped rows. + +### G-4. Grant / discount / tax application order (joint contract) + +**State today**: the **within-rating** order is compiled and normative — +[rating PRD](../../rating/docs/PRD.md) §17.1 pins base row → overlays (step 4) → contract (5) +→ commitment (6) → coupon in price currency (7) → FX + billing-currency coupons (8) → emit +(9), with the period floor/cap applied after step 9 by Billing. What no document pins is the +**Billing-side continuation**: where **prepaid-grant drawdown** (GA-gated Billing/Rating +execution, outside steps 1–9) and **tax** sit relative to the post-coupon amount. Rating +explicitly declines to own it — "Tariffs supplies lineage, not the gross-vs-net ordering +decision" ([rating design/06-coupons.md](../../rating/docs/design/06-coupons.md) §4.5): the +lineage keeps either answer computable, but nothing selects the answer. + +**Stripe** fixes it explicitly: credits apply **after discounts, before taxes** (and before +the invoice credit balance). We need the same one-line normative statement on the +**Billing-facing joint contract** (no Billing gear doc exists yet to hold it — carry it next +to the grant definition in [10-advanced-primitives.md](./design/10-advanced-primitives.md) +and mirror it in the rating consumer contracts until Billing docs exist). Low effort, high +clarity. + +## 6. Recommendation summary + +| # | Gap | Effort | Recommended action | +|---|---|---|---| +| G-1 | Coupons / promotion codes | Gear PRD (known open) | Application side already designed (rating 06 / §17.2); stand up Promotions PRD from that contract; put Stripe `duration` on the §15 alignment agenda; typed-`discountRef` bridge rejected | +| G-2 | Prepaid grant `applicability` + `category` (+ `priority`) | Low–Med | **Actioned → D-43** (fields added + materialized at publish; effective order Billing-owned; veto-flagged) | +| G-3 | Monetary minimum commitment / true-up | — (resolved by inspection) | Already owned: negotiated committed spend = Contracts pools + `TrueUpObligation` (rating T-D-14, step 6); plan-level period floor = conscious §17.8 deferral with `PeriodFloorCapObligation` machinery reserved; no new decision needed | +| G-4 | Grant-drawdown / tax placement after coupons | Low | Within-rating order already pinned (§17.1); pin the Billing-side continuation (drawdown, tax) as a normative joint-contract line | + +Everything in §2 (customer object), §3 (HAVE), and §4 (§17.8 deferred) needs **no action** — +recorded to make the boundary and the conscious deferrals explicit. diff --git a/gears/bss/pricing/docs/design/01-foundation.md b/gears/bss/pricing/docs/design/01-foundation.md new file mode 100644 index 000000000..0143f325b --- /dev/null +++ b/gears/bss/pricing/docs/design/01-foundation.md @@ -0,0 +1,410 @@ + + + +# DESIGN — Catalog Foundation (shared publish engine) (Slice 1) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-design-foundation` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Canonical Scope Key (normative)](#41-canonical-scope-key-normative) + - [4.2 Publish-Through-The-Engine Contract (normative)](#42-publish-through-the-engine-contract-normative) + - [4.3 Immutability and Change Mechanisms (normative)](#43-immutability-and-change-mechanisms-normative) + - [4.4 Read Model and pricingSnapshotRef (normative)](#44-read-model-and-pricingsnapshotref-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The Catalog Foundation is the shared publish engine that every Plan & Price Modeling +capability builds on. It owns the `Plan`/`Price` entity model, the **canonical scope key**, +the draft→publish state machine, the **fail-closed validation pipeline** framework, +append-only published-row history with versioning/supersession, the **read-model projection** +and `pricingSnapshotRef` stamping, and the **frozen event fan-out** plus the +`CatalogVersion`-increment request to the registry. It owns **no capability policy**: what a +billing cycle means, how a tier band validates, what a bundle is — all live in a handler slice +(plan-definition, price-structure, and the rest), each of which authors draft state, registers +its validation rules and its read-model fields, and **publishes through** this Foundation's +API under the invariants defined here. + +The catalog's contract is the mirror image of the sibling Billing Ledger's *post through the +engine*: it is **publish through the engine**. Every state change reaches production one way — +author draft → run the aggregate fail-closed validation pipeline → freeze a complete read +model + `pricingSnapshotRef` → emit the frozen event set → request a `CatalogVersion`. There +is no side door that mutates published state, no consumer that reads draft, and no default +substituted for an absent field (absence must have failed publish). This keeps the +correctness-critical publish/immutability/determinism core small and auditable while letting +each pricing capability evolve independently ([`../PRD.md`](../PRD.md) §1.1, §2). + +The gear is **one deployable modular monolith** (`pricing`) running in two roles — a +synchronous authoring/publish/preview API and a read-model service — over one PostgreSQL +backend. The authoring path is transactional — draft mutation, validation, publish-commit, and event +enqueue commit atomically **at the post-approval publish step** (the Slice 5 approval gate for +material changes sits between the validation pre-check and that commit); the read path is +served from a projected read model for the p95 < 100ms target and **fails closed** (never +stale) on read-model outage. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-pricing-fr-publish-validation-failclosed` | A single **aggregate fail-closed validation pipeline** runs at publish; slices register rules keyed to the §17.4 rule set; any invalid condition blocks the publish transaction (no `PlanPublished`, no read-model warm). The validation report enumerates every failure so authoring can remediate. | +| `cpt-cf-bss-pricing-fr-published-rows-append-only` | `pricing_price` rows in a published state are append-only: `REVOKE UPDATE, DELETE` from the app role + `BEFORE UPDATE OR DELETE` triggers that RAISE, with a **column whitelist** for the two sanctioned transitions (state-machine `lifecycle_state` flips; monotonic `grandfather_until` tightening — §3.7/§4.3); only never-published `draft` rows are deletable. There is no deletion event to fan out. | +| `cpt-cf-bss-pricing-fr-plan-versioning` | A price/tier change versions the `Plan` and writes **new** immutable `pricing_price` rows; prior rows are retained as history; bound subscriptions continue on their frozen snapshot until renewal or migration. | +| `cpt-cf-bss-pricing-fr-supersession` | Supersession is versioning scoped to **one canonical scope key**: a new immutable row plus opening/closing the corresponding `PriceWindow` (never an in-place mutate, never an overlap), within one `priceEligibility` class and one `chargeKind`. | +| `cpt-cf-bss-pricing-fr-pricing-snapshot` | Publish stamps the catalog-side identifiers sufficient for the manifest `pricingSnapshotRef` (resolved price ids + evaluation-policy version + the **pending** version ref, finalized to the committed `CatalogVersion` on `CatalogVersionPublished`); posted periods never re-query mutable rows; the catalog-side view MUST NOT diverge from the Tariffs composition SoR. | +| `cpt-cf-bss-pricing-fr-consumer-readmodel-resolution` | The projected read model is **monotonic per `CatalogVersion`** (ignored until `CatalogVersionPublished` + a warm-completion marker); consumers resolve exact published values with no draft read and no default substitution; a rating run pins one version and the pin never lags the newest completed version by > 5s. | +| `cpt-cf-bss-pricing-fr-catalogversion-increment` | On every `PlanPublished` the Foundation requests addressability; the registry is the **sole** incrementer and MAY batch approved publishes; `PlanPublished` carries a **pending** ref and the snapshot pins the committed version on `CatalogVersionPublished`. | +| `cpt-cf-bss-pricing-fr-publish-fanout-atomicity` | Post-commit read-model warming retries to the 5s SLO or marks the publish degraded (`PlanPublishDegraded`); no state exposes a rateable-but-incomplete plan; the pre-commit batching delay is governed by the max batching-delay SLO, not by degraded handling. | +| `cpt-cf-bss-pricing-fr-event-contract` | A **frozen event-name set** (`PlanCreated`, `PlanUpdated`, `PlanPublished`, `PlanRetired`, and conditionally `PlanMigrationScheduled`, `PlanPublishDegraded`, `BundleUpdated`, `PriceCreated`, `PriceUpdated`, plus the manifest `PriceWindowScheduled`/`Activated`/`Expired`/`Cancelled` — produced by this gear since the window consolidation, D-03) emitted from a transactional outbox, ordered per `(tenantId, aggregateId)`, at-least-once, carrying correlation/idempotency keys. | +| `cpt-cf-bss-pricing-fr-price-amount-validation` | Amount ≥ 0, valid ISO 4217, precision = the currency's ISO 4217 minor unit; a missing `(currency, region)` row fails closed (no implicit FX). | +| `cpt-cf-bss-pricing-fr-mutation-idempotency` | Plan/Price create/update accept a client idempotency key; a duplicate returns the original outcome without a second mutation. | +| `cpt-cf-bss-pricing-fr-concurrent-edit` | Optimistic concurrency (ETag/row version) rejects a stale submit and a bulk-vs-interactive collision with a conflict; neither change is silently overwritten. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| Publish → read-model propagation (p95 ≤ 5s) | Publish engine + outbox + read-model warmer | Batched `CatalogVersion` commit; retry-to-SLO warm or `PlanPublishDegraded`; pin never lags newest completed by > 5s | Load test on the publish→warm path; **max batching-delay SLO value open with Registry** ([`../PRD.md`](../PRD.md) §15) | +| Read / preview latency (p95 < 100ms per tenant partition) | Read-model projection store | Single indexed, version-pinned read; no evaluation on the read path | APM on read APIs | +| Determinism / reproducibility | Snapshot + append-only history | Complete frozen `pricingSnapshotRef`, monotonic per version, append-only rows | Design + integration test (later-version publish does not alter a prior snapshot) | +| Read-model availability / DR RPO-RTO | Read-model store + topology | Fail-closed on outage (never stale) | **Provisional — ratify before Design lock** ([`../PRD.md`](../PRD.md) §14) | +| Idempotency-key TTL; plan/tier size caps | Publish engine | Idempotency-dedup store; publish-time size validation | **Provisional — ratify before Design lock** | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-pricing-adr-canonical-scope-key` | The single scope key is `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` — the manifest key extended additively so hybrid components and a grandfathered row + its successor are distinct keys with concurrent active windows. | +| `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` | Multi-generation grandfathering: the additive `cohort` axis (= the cutover instant; `none` on non-grandfathered rows) makes every cutover a **new** generation on its own key; within the grandfathered class Tariffs selects by the cohort of the subscription's pinned price id. | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` | The `PriceWindow` machinery is gear-owned (Slice 7): store, state machine, activation job, `PriceWindow*` production; multi-window units are local ACID transactions. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-pricing-tech-stack` + +```text +Capability slices (modules) plan-definition · price-structure · currency-tax · governance · + │ (publish API: authorDraft / validate / publish / projectReadModel / requestVersion) + ▼ +Publish Engine (Foundation) ScopeKey · DraftStateMachine · ValidationPipeline · + VersioningStore · ReadModelProjector · SnapshotStamper · EventOutbox + │ + ▼ +PostgreSQL plan / price (truth + append-only history) · read_model (projection) · + catalog_version_ref · outbox · policy objects · audit store +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | REST authoring/publish/preview + read-model surfaces behind the inbound gateway; RFC 9457 problems; OAuth 2.0; ETag optimistic concurrency | Rust, REST/OpenAPI, inbound API gateway | +| Application | Capability slices author draft state and register rules/read-model fields | Rust modules in the `pricing` monolith | +| Domain | The publish engine, canonical scope key, validation pipeline, versioning/immutability, snapshot contract | Rust; GTS + Rust domain structs | +| Infrastructure | Append-only history, projected read model, audit store, transactional outbox | PostgreSQL (single primary + replicas), SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Foundation owns publish; slices own capability policy + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-foundation-owns-publish-fnd` + +No slice defines the scope key, emits an event, or stamps a snapshot; the Foundation defines +no capability semantics. Slices author draft state, register validation rules and read-model +fields, and publish through the Foundation API. Normative: [§4.2](#42-publish-through-the-engine-contract-normative). + +#### Fail closed, always + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-fail-closed-fnd` + +Any invalid or ambiguous condition blocks the publish transaction; the absence of a required +field is a publish failure, never a downstream default. Consumers never read draft state. + +#### Published state is append-only + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-principle-append-only-fnd` + +Published `pricing_price` rows are immutable history; change is a new immutable row via +versioning/supersession + `PriceWindow`. Only never-published `draft` rows are deletable. +Normative: [§4.3](#43-immutability-and-change-mechanisms-normative). + +### 2.2 Constraints + +#### Money is ISO 4217 minor units + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-money-fnd` + +Amounts are stored as integer minor units at the currency's ISO 4217 scale (0 for JPY/KRW, 2 +default, 3 for BHD/KWD/OMR); a flat 2-decimal cap MUST NOT be assumed; amounts are `≥ 0` +(negatives rejected); no implicit FX — a missing `(currency, region)` row fails closed. + +#### Author-driven mutation; UTC time + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-author-driven-fnd` + +The catalog mutates state only in response to explicit authoring/publish/lifecycle calls; it +does not self-originate rows. All effective dating, window boundaries, `grandfatherUntil`, +`availableFrom`/`availableTo`, and anchor math are UTC. + +#### AuthZ gate before the repository + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-constraint-authz-gate-fnd` + +Every ctx-bearing service path calls the shared PEP `access_scope` gate +(`authz_resolver_sdk`) with its catalogued `(resource_type, action)` pair **before** touching +the repository; the PDP-compiled `AccessScope` is the SQL filter SecureORM binds (reads) and +the write-target membership assertion (writes). The resource/action catalog, the +endpoint mapping, and the role matrix are normative in the governance slice +([`05-governance.md`](./05-governance.md) §AuthZ Resource and Action Catalog); labels are +GTS ids `gts.cf.bss.pricing..v1~` outside `gts.cf.resources.*`, registered as stub +type-schemas at gear init. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-domain-model-fnd` + +The Foundation owns four core aggregates; capability slices extend them with their own fields +and child tables (phases, add-on rules, bundles, price overlays) without redefining the core. + +- **`Plan`** — binds a **published** `skuId` to a billing cycle, a mandatory `PlanTier`, optional plan phases and composition rules, and optional `availableFrom`/`availableTo` purchasability dates. Carries a lifecycle state (`draft` → `published` → `retired`) and a monotonic revision. Capability meaning of the cycle/composition fields is owned by the plan-definition slice. +- **`Price`** — a price row on the **canonical scope key** (§4.1) with an amount (ISO 4217 minor units), `modelKind`, tier bands, evaluation-policy fields, `taxInclusive`, lifecycle metadata (`priceEligibility`, optional `grandfatherUntil`), and a supersession pointer to the row it replaces within its scope key. Published rows are append-only; a prior row is retained as history. +- **`ReadModel`** — the projected, per-`CatalogVersion` frozen view a consumer resolves: `{skuId, planId, priceId}`, model kind, ordered tier bands, evaluation-policy fields, phase→price map, **phase→grant-set map** (per-phase entitlement grant set when authored — D-41; else the plan-level `PlanTier`-driven grant set), billing descriptors, and the consumer contracts (proration/plan-change/entitlement) contributed by their slices. Monotonic per version; never reflects draft state. +- **`pricingSnapshotRef`** — the composite reference (resolved price ids + evaluation-policy version + version ref) whose catalog-side identifiers are stamped at publish with a **pending** version ref and **finalized** to the committed `CatalogVersion` on `CatalogVersionPublished`, immutable thereafter; pinned by consumers (composition SoR: Tariffs). + +Supporting Foundation objects: the **tenant policy objects** (approval-threshold policy, +tax-display policy — both fail-safe), the **idempotency-dedup** store, the **transactional +outbox**, and the **audit store** (append-only, actor/before-after/approval trail). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-component-foundation-fnd` + +The Foundation is a set of in-process components behind one publish API: + +- **`ScopeKey`** — constructs and validates the canonical scope key, applies the axis defaults (`priceOverlay = base`, `phase =` the plan's terminal `phase_id`, `priceEligibility = all_subscriptions`, `cohort = none`), and backs the row-uniqueness index. +- **`DraftStateMachine`** — the `draft` → `published` → `retired` transitions; only `draft` rows are mutable/deletable. +- **`ValidationPipeline`** — runs the aggregate fail-closed rule set at publish; slices register rules; a single failure blocks the publish transaction and populates the validation report. +- **`VersioningStore`** — writes new immutable rows on versioning/supersession, retains history, and enforces append-only via role + triggers. +- **`ReadModelProjector`** — materialises the frozen per-version read model and drives warm-completion; fails closed on outage. +- **`SnapshotStamper`** — stamps the catalog-side identifiers sufficient for the manifest `pricingSnapshotRef` (composition SoR: Tariffs) and requests the `CatalogVersion` increment from the registry. +- **`EventOutbox`** — emits the frozen event set transactionally, ordered per `(tenantId, aggregateId)`, at-least-once with dedup keys. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-interface-authoring-publish-fnd` + +The **authoring + publish** contract (`cpt-cf-bss-pricing-interface-authoring-publish`): +create/update/clone plans and price rows in `draft`, run fail-closed validation, submit for +approval (two-person rule for material changes), and publish — emitting the frozen event set +and requesting a `CatalogVersion`. Accepts client idempotency keys; enforces optimistic +concurrency via ETag/row version. + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-interface-catalog-read-model-fnd` + +The **published read model** contract (`cpt-cf-bss-pricing-interface-catalog-read-model`): +per committed `CatalogVersion`, the complete plan/price read model resolvable via +`pricingSnapshotRef`, monotonic per version, no draft reads, additive-only within a major +version. + +The base-price **preview** (`cpt-cf-bss-pricing-interface-price-preview`) and the external +integration contracts ([`../PRD.md`](../PRD.md) §9.2) are refined in the slices that own their +payloads. Concrete schemas, proto, and **slice-specific** error taxonomies are owned by these +slice designs. Failure modes of the engine itself carry **Foundation-owned** RFC 9457 problem +types, referenced (never redefined) by slices: `DUPLICATE_SCOPE_KEY` (409 — canonical +scope-key uniqueness), `STALE_VERSION` (409 — ETag/row-version conflict), +`IDEMPOTENCY_PAYLOAD_MISMATCH` (409 — same key, different payload), the aggregate validation +report envelope (422 — enumerating blocking `violations[]` plus advisory `warnings[]`), and +publish-accepted/pending (202). + +### 3.4 Internal Dependencies + +- **`toolkit-db`** — transactional persistence for the append-only history, the owned window store (Slice 7, D-03), the projected read model, the outbox, and the audit store. +- **Coordination lease library** — singleton coordination for read-model warm re-drive and the window activation/expiration job (Slice 7). + +### 3.5 External Dependencies + +- **Catalog registry (Product & SKU)** — published `skuId`, `bundle` SKU type, `meteringUnit` declaration, `PlanTier` taxonomy; the **sole** `CatalogVersion` incrementer. Bidirectional `CatalogVersion`-increment contract (`cpt-cf-bss-pricing-contract-registry-catalogversion`). +- ~~Effective-dating PriceWindows use case~~ — **consolidated into this gear** (Slice 7 owns the window store, state machine, activation job, and `PriceWindow*` emission — D-03); `cpt-cf-bss-pricing-contract-pricewindow` is thereby internal, not an external boundary. +- **Tariffs / Subscriptions / Rating / Billing** — consume the read model / events; their payloads are refined in the consumer-contracts and capability slices. + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-seq-author-publish-fnd` + +**Author → validate → approve → publish → CatalogVersion.** A slice authors/updates draft rows +(ETag checked, idempotency-deduped); submit runs the ValidationPipeline as a **pre-check** and +routes a material change through the Slice 5 approval gate; on approval (or immediately for a +below-threshold change) the publish commit **re-runs the aggregate rule set inside the commit +transaction** (approval approves content; the commit re-validates state — a failure at commit +voids the approval and returns the subject to draft with the report); on success the row set +transitions to `published` (append-only), the EventOutbox enqueues `PlanPublished` with a +**pending** version ref, and the SnapshotStamper requests a `CatalogVersion`. The registry batches approved publishes and emits +`CatalogVersionPublished`; the ReadModelProjector warms the projection and marks completion, or +the publish is marked degraded (`PlanPublishDegraded`). `pricingSnapshotRef` pins the committed +version. No intermediate state exposes a rateable-but-incomplete plan. A +`pricing_catalog_version_ref` still `pending` past the max batching-delay SLO raises a +Critical alarm (`pricing.catalogversion.commit_overdue`) and surfaces on the publish status +API; a `CatalogVersionPublished` batch that omits an expected pending ref is treated the same +— remediation is a registry re-request, never a silent re-emit. + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-seq-readmodel-resolution-fnd` + +**Consumer read-model resolution.** A consumer pins one committed `CatalogVersion` and resolves +the complete frozen read model via `pricingSnapshotRef` — no draft read, no default +substitution, monotonic per version; the pin never lags the newest completed version by > 5s. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-schema-fnd` + +Foundation-owned tables (tenant-scoped, SecureORM). Money columns are integer minor units at +the currency's ISO 4217 scale. **Table-naming discipline (normative):** every physical table +of this gear carries the gear-name prefix **`pricing_`** — matching the sibling ledger's +`ledger_*` convention (`ledger_journal_entry`, `ledger_idempotency_dedup`, …). Domain entity +names (`Plan`, `Price`, `ReadModel`) stay unprefixed; the prefix applies to physical tables +only, including slice-owned tables in every other slice design. + +- **`pricing_plan`** — `plan_id` (PK), `tenant_id`, `sku_id`, `plan_tier`, `billing_cycle`, `lifecycle_state` (`draft`|`published`|`retired`), `revision`, `available_from`/`available_to`, ETag/row-version. Updated in place only through DraftStateMachine transitions (`revision` increments monotonically, `lifecycle_state` per §3.2), fully audited; physical append-only enforcement applies to `pricing_price`. +- **`pricing_price`** — `price_id` (PK), `tenant_id`, the **canonical scope-key columns** (`plan_id`, `currency`, `region`, `price_overlay`, `phase`, `price_eligibility`, `charge_kind`, `cohort` — `none` unless `existing_grandfathered`, ADR-0002), `amount_minor`, `model_kind`, `tax_inclusive`, `billing_timing` (recurring), evaluation-policy columns (usage), `grandfather_until`, `supersedes_price_id`, `lifecycle_state`. **Partial `UNIQUE`** on the scope key over **current** rows (`lifecycle_state = 'published'` and not superseded, via the supersession link) enforces at most one current row per key — **temporal `PriceWindow` non-overlap and coverage are enforced by the publish-time validation pipeline (Slice 7, gear-owned per D-03), not by this index**, so a published predecessor and its scheduled successor legally coexist. Append-only via `REVOKE UPDATE, DELETE` + `BEFORE UPDATE/DELETE` trigger with a **column whitelist**: the trigger rejects any UPDATE of a published row except (a) `lifecycle_state` transitions permitted by the state machine (`published → superseded` on supersession/cutover) and (b) monotonic tightening of `grandfather_until` (setting it when null, or moving it earlier); all price/scope/model columns are immutable and DELETE is always rejected — controlled transitions run through the engine's transition path, never ad-hoc SQL. +- **Price history** — history is the set of superseded rows retained **in `pricing_price` itself**, keyed by `supersedes_price_id`; no rows are ever moved or deleted (no separate history table). +- **`pricing_read_model`** — the projected frozen view keyed by `(tenant_id, catalog_version, plan_id)` with a `warm_completed` marker; monotonic per `catalog_version`. +- **`pricing_catalog_version_ref`** — `pending` vs `committed` version linkage per publish. +- **`pricing_policy_object`** — the approval-threshold and tax-display policies (fail-safe defaults). +- **`pricing_idempotency_dedup`** — PK `(tenant_id, operation, client_key)` + a request-payload hash; the at-most-once gate + replay-response source. A replay with a matching hash returns the stored response; a mismatching hash is rejected with `IDEMPOTENCY_PAYLOAD_MISMATCH` (never replayed, never re-executed); the idempotency check precedes the ETag check. +- **`pricing_outbox`** — the transactional event outbox (frozen event names, dedup/correlation keys, `(tenantId, aggregateId)` ordering). +- **`pricing_audit_log`** — append-only actor/before-after/approval trail; ≥ 7-year configurable retention. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-pricing-deployment-fnd` + +Stateless authoring/publish + read-model service over a shared `toolkit-db` backend; +background work (read-model warm re-drive) is coordinated as a singleton via the coordination +lease library. The read path is served from the projected read model and fails closed on +outage. Deployment specifics are platform-standard for a BSS gear. + +## 4. Additional Context + +### 4.1 Canonical Scope Key (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-normative-scope-key` + +The single scope key for **row-uniqueness, supersession, `PriceWindow` non-overlap, and window +coverage** is: + +```text +(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort) +``` + +- Axis defaults: `priceOverlay = base` (rows authored here always carry `base`; partner/orgTier/brand overlays are separate `PriceOverlay` rows evaluated downstream by Tariffs), `phase =` **the plan's terminal `phase_id`** (D-19: the axis is always uuid-typed — for a phased plan its authored terminal phase; for non-phased/one-time plans an **implicit terminal phase row** (kind `evergreen`) is auto-created at plan creation and its id is the default; the literal `evergreen` survives only as the phase *kind*), `priceEligibility = all_subscriptions`, `chargeKind` per row, `cohort = none`. +- `cohort` (ADR-0002) is the **grandfathering generation discriminator** — the UTC cutover instant that created the generation. Publish validation enforces `cohort ≠ none ⇔ priceEligibility = existing_grandfathered`; every cutover creates a **new** generation on its own key, so repeated repricing with per-cohort retention never violates non-overlap. Within the `existing_grandfathered` class, Tariffs selects the row whose `cohort` equals the cohort of the subscription's **pinned price id** (`pricingSnapshotRef`); class ordering (most-specific-wins) is unchanged. Unrelated to `customerGroup` segment pricing. +- `chargeKind ∈ {recurring, usage, one_time, one_time_setup}` distinguishes the components a single plan legitimately carries at once: a hybrid plan holds a `recurring` **and** a `usage` row (optionally a `one_time_setup` row) on one `planId`, and a one-time plan's base row is `one_time` — so they are **distinct keys**, not duplicates. +- `brand` is **NOT** a price-row axis: brand-differentiated pricing is a **brand-scoped `PriceOverlay`** overlay (manifest §4.1 invariant). +- This key **extends the manifest `(plan, currency, region, priceOverlay)` key additively** with `phase`, `priceEligibility`, `chargeKind` (ADR `cpt-cf-bss-pricing-adr-canonical-scope-key`) and `cohort` (ADR `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`), and **supersedes** the narrower effective-dating `(plan, currency, region, priceOverlay)` key for normative purposes. + +Because `priceEligibility` and `cohort` are part of the key, a grandfathered generation and +its successor — and any number of prior generations — are **distinct keys** that hold active +windows concurrently at the same instant without violating non-overlap (§4.3). + +### 4.2 Publish-Through-The-Engine Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-normative-publish-contract` + +Every state change that reaches production follows one path: + +1. **Author draft** — create/update/clone in `draft` (ETag-checked, idempotency-deduped). Only `draft` rows are mutable/deletable. +2. **Validate (fail closed)** — the aggregate validation pipeline runs the §17.4 rule set plus every slice-registered rule; a single failure blocks the submission with an enumerated report. This step is a **pre-check**: the same rule set re-runs inside the publish-commit transaction of step 4 (approval approves content; the commit re-validates state — a commit-time failure voids the approval and returns the subject to draft with the report). No `PlanPublished`, no read-model warm on any failure. +3. **Approve** — a material change (above the configured threshold, or a first publish) requires the submitter **plus ≥ 1 independent approver** (two distinct principals; self-approval rejected + audited). Fail-safe: the two-person rule applies unless an explicit threshold is configured and the change is below it and it is not a first publish. Pending approval is **not** a `lifecycle_state`: the subject stays `draft` and remains mutable — the open Slice 5 approval record marks it, and any mutation of the subject **voids** that record ("returns to draft" in the PRD means the approval record closes). +4. **Freeze + emit** — the publish commit re-runs the pipeline, transitions the row set, and stamps the catalog-side `pricingSnapshotRef` identifiers; the frozen event set is enqueued transactionally (`PlanPublished` with a **pending** version ref). +5. **Version + warm** — the registry (sole incrementer) batches approved publishes and emits `CatalogVersionPublished`; the read model warms to the 5s SLO or the publish is marked degraded (`PlanPublishDegraded`). No intermediate state exposes a rateable-but-incomplete plan. + +Publish units are not only plans: Slice 9's `PriceOverlays` and customer-group +membership mutations publish through the **same engine** (validation → pending ref → warm; +D-06) — nothing becomes consumer-visible outside a committed `CatalogVersion`. + +Consumers never read draft state and never substitute a default for an absent +evaluation-policy field (absence must have failed step 2). The catalog computes **no** monetary +charge, evaluates **no** overlay, and performs **no** FX. + +### 4.3 Immutability and Change Mechanisms (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-normative-immutability` + +Published `pricing_price` rows are **append-only history**: `REVOKE` + a trigger with a +**column whitelist** reject any UPDATE except the state-machine `lifecycle_state` transitions +(`published → superseded`) and monotonic `grandfather_until` tightening — price/scope/model +columns are immutable and DELETE is always rejected (§3.7); only never-published `draft` rows +are deletable; there is no deletion event to fan out. Change over time uses four **distinct, +composable** mechanisms ([`../PRD.md`](../PRD.md) §17.5): + +- **Versioning** — captures a structural/price change as a new immutable revision; prior rows retained as history (`PlanUpdated` / `PriceCreated`). +- **Supersession** — versioning scoped to **one canonical scope key**: a new immutable row plus opening/closing the corresponding `PriceWindow` (never overlap), within one `priceEligibility` class and one `chargeKind` (`PriceUpdated`). +- **`PriceWindow`** — schedules **when** a versioned/superseded row is effective (window store, state machine, and activation job owned by Slice 7 in this gear — D-03). +- **Grandfathering cutover** — one atomic approval unit that shortens the current `all_subscriptions` window `effectiveTo` to the cutover and schedules (a) an immutable `existing_grandfathered` copy and (b) the `all_subscriptions` successor, so **no coverage gap opens**. + +An `existing_grandfathered` row is **immutable in price** and MUST NOT be superseded; the only +permitted mutation is **setting or tightening `grandfatherUntil`** (never loosening, never the +price), which is a material change. Because it is a distinct scope key (via `priceEligibility`), +it holds an active window concurrently with its successor and is **live-resolved** by Tariffs +against an immutable row — reconciling live resolution with the frozen-snapshot doctrine. + +### 4.4 Read Model and pricingSnapshotRef (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-normative-read-model` + +The published read model is **monotonic per `CatalogVersion`**: a version is ignored until its +`CatalogVersionPublished` **and** the warm-completion marker are both present. A consumer pins +**one** `CatalogVersion` for the duration of a resolution/rating run and resolves the complete +frozen view via `pricingSnapshotRef`; at pin time the pinned version MUST NOT lag the newest +completed version by more than 5s. There is **no** draft read and **no** default substitution. + +`pricingSnapshotRef` is the composite reference (`CatalogVersion` + resolved price ids + +evaluation-policy version) pinned on charges and `BillableItem`s — stamped at publish with the +**pending** version ref, finalized to the committed `CatalogVersion` on +`CatalogVersionPublished`, and immutable thereafter; posted invoice periods MUST NOT re-query +mutable catalog rows. The **normative composition SoR is Tariffs**; the catalog-side view is +the aligned entry and MUST NOT diverge from it. On read-model outage the read path **fails +closed** (never serves stale). After a degraded publish the warm **re-drive continues past the +SLO**; on completion it sets the warm-completion marker (the version becomes resolvable — +monotonicity unaffected) and clears the degraded mark, raising an operations alarm meanwhile; +no new event name is introduced — consumers observe completion via the marker. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) — §2.2 (canonical scope key), §6.2/§6.7 (model kind, publish, events), §6.8 (versioning/immutability/supersession), §6.9 (consumer resolution), §9 (interfaces), §17.4 (validation rules), §17.5 (change mechanisms + `CatalogVersion` increment) +- **DESIGN**: [`../DESIGN.md`](../DESIGN.md) — canonical index (slice map, dependency order, cross-cutting statements) +- **ADRs**: [`../ADR/`](../ADR/) — `cpt-cf-bss-pricing-adr-canonical-scope-key`, `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`, `cpt-cf-bss-pricing-adr-pricewindow-consolidation` + +This slice directly addresses: + +- `cpt-cf-bss-pricing-fr-publish-validation-failclosed` — the aggregate fail-closed validation pipeline +- `cpt-cf-bss-pricing-fr-published-rows-append-only` / `cpt-cf-bss-pricing-fr-plan-versioning` / `cpt-cf-bss-pricing-fr-supersession` — append-only history + versioning/supersession +- `cpt-cf-bss-pricing-fr-pricing-snapshot` / `cpt-cf-bss-pricing-fr-consumer-readmodel-resolution` — snapshot + monotonic read model +- `cpt-cf-bss-pricing-fr-catalogversion-increment` / `cpt-cf-bss-pricing-fr-publish-fanout-atomicity` / `cpt-cf-bss-pricing-fr-event-contract` — `CatalogVersion` request, degraded handling, frozen event set +- `cpt-cf-bss-pricing-fr-price-amount-validation` / `cpt-cf-bss-pricing-fr-mutation-idempotency` / `cpt-cf-bss-pricing-fr-concurrent-edit` — money/precision, idempotency, optimistic concurrency +- `cpt-cf-bss-pricing-interface-authoring-publish` / `cpt-cf-bss-pricing-interface-catalog-read-model` — the two primary API contracts diff --git a/gears/bss/pricing/docs/design/02-plan-definition.md b/gears/bss/pricing/docs/design/02-plan-definition.md new file mode 100644 index 000000000..e8760010b --- /dev/null +++ b/gears/bss/pricing/docs/design/02-plan-definition.md @@ -0,0 +1,432 @@ + + + +# DESIGN — Plan Definition, Billing Cycles & Composition (Slice 2) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author a Plan](#author-a-plan) + - [Publish a Plan](#publish-a-plan) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Billing-Cycle Shape Validation](#billing-cycle-shape-validation) + - [Plan Composition Validation](#plan-composition-validation) + - [Phase Schedule Validation](#phase-schedule-validation) + - [Billing Descriptor Completeness](#billing-descriptor-completeness) +- [4. States (CDSL)](#4-states-cdsl) + - [Plan Lifecycle State Machine](#plan-lifecycle-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Billing-Cycle Matrix](#billing-cycle-matrix) + - [Plan Composition & PlanTier](#plan-composition--plantier) + - [Phases](#phases) + - [Descriptors](#descriptors) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **shape of a Plan**: the billing-cycle matrix (one-time / recurring / +usage-based / hybrid), custom frequency, per-seat quantity provenance, the optional one-time +setup row, mandatory `PlanTier`, meter injectivity, add-on rules, plan phases with +`convertsToPhaseId`, and the billing descriptor set. It registers its validation rules into +the Foundation's fail-closed pipeline and its fields into the read-model projection; it owns +**no publish mechanics** — everything publishes through the Foundation +([`01-foundation.md`](./01-foundation.md) §4.2). + +**Traces to**: `cpt-cf-bss-pricing-fr-billing-cycles`, `cpt-cf-bss-pricing-fr-custom-frequency`, +`cpt-cf-bss-pricing-fr-hybrid-completeness`, `cpt-cf-bss-pricing-fr-per-seat`, +`cpt-cf-bss-pricing-fr-one-time-setup`, `cpt-cf-bss-pricing-fr-plantier-mandatory`, +`cpt-cf-bss-pricing-fr-meter-injective`, `cpt-cf-bss-pricing-fr-addon-rules`, +`cpt-cf-bss-pricing-fr-billing-descriptors`, `cpt-cf-bss-pricing-fr-plan-phases` + +### 1.2 Purpose + +Give Finance/Product a self-service way to author every launch commercial shape on one +`planId` — recurring base + usage + optional setup, per-seat with explicit quantity +provenance, phased trial→intro→evergreen — such that an invalid or ambiguous shape **cannot +publish** and a published shape is completely resolvable by Subscriptions/Tariffs/Billing +without defaults. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Authors plans, cycles, phases; submits for publish | +| `cpt-cf-bss-pricing-actor-product-manager` | Configures add-on rules and composition | +| `cpt-cf-bss-pricing-actor-catalog-registry` | Supplies published SKUs, `PlanTier` taxonomy, `meteringUnit` declarations | +| `cpt-cf-bss-pricing-actor-subscriptions` | Consumes phase map, `displayTrialDays`, sellability inputs | +| `cpt-cf-bss-pricing-actor-rating` | Consumes the meter mapping (injectivity guarantee) | +| `cpt-cf-bss-pricing-actor-billing` | Consumes billing descriptors via `CatalogVersion` | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.1, §6.3, §17.1 (billing-cycle matrix), §17.3 (composition rules) +- **Design**: [01-foundation.md](./01-foundation.md) — publish contract (§4.2), scope key (§4.1), schema ownership +- **Dependencies**: Foundation (Slice 1). Co-required with price-structure (Slice 3): a rateable plan needs both a shape and a model kind. + +### 1.5 Scope + +**In scope**: billing cycles + custom frequency metadata; hybrid completeness; per-seat +quantity provenance concept (persistence + validation of `quantitySource`: Slice 3); one-time +setup row validation; `PlanTier` mandatory + SKU-equality check; +meter injectivity; add-on rules (dependency, bounds, override reference); phases (ordering, +`convertsToPhaseId`, terminal phase, `displayTrialDays`); billing descriptor completeness. + +**Out of scope**: tier bands / model kinds (Slice 3); bundles (Slice 8); windows/sellability +enforcement (Slice 7); trial runtime, entitlement enforcement, proration math (Subscriptions); +`PlanTier` taxonomy and `meteringUnit` declaration (registry); charge computation (Tariffs). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set (fail-closed, append-only, UTC, ISO 4217, tenant isolation). Slice-2-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| P1 | Custom interval cap | `customEveryN{Days\|Months}(n)`: `n > 0` and `n ≤` a tenant-configured cap; over-cap config rejected at authoring (no silent clamp) | PRD §6.1 | +| P2 | Custom-frequency anchoring | `customEveryN Days(n)` MUST anchor on `subscription_start` (a `calendar_month`/`fixed_day` anchor fails publish). `customEveryN Months(n)` MAY anchor `subscription_start` or `calendar_month`; a `subscription_start` day beyond the target month clamps to its last day (K2 rule) with the **anchor day preserved** per period (no drift: 31→28→31); UTC; joint anchor fixture with Subscriptions (D-20) | PRD §6.1; D-20 | +| P3 | PlanTier equality | Plan `PlanTier` = parent SKU `PlanTier` unless an explicit, audited override is declared (default equal, no silent divergence) | PRD §17.3 | +| P4 | Localization | Plan-owned display content (names, labels, descriptors) is single-language at launch; per-locale authoring is an open registry-owned item | PRD §15 (F-37) | +| P5 | Descriptor minimum field set | The Billing/ERP minimum descriptor field list is open with Billing/Payments; the manifest §4.1 set (line template, tax category, GL code, itemization) is the working baseline | PRD §15 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary and inherits engine mechanics from the Foundation (`ScopeKey`, +`DraftStateMachine`, `ValidationPipeline`, `ReadModelProjector`, `EventOutbox`). Not restated. + +Design-introduced names (Slice 2): + +| Name | Meaning | +|------|---------| +| `CycleShapeValidator` | Registered rule set validating the billing-cycle matrix (§17.1) per plan | +| `CompositionValidator` | Registered rule set for `PlanTier`, meter injectivity, add-on rules (§17.3) | +| `PhaseGraph` | The ordered phase set with `convertsToPhaseId` edges; validated acyclic with exactly one terminal phase | +| `DescriptorSet` | The per-plan billing descriptor aggregate checked for completeness at publish | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph upstream["Upstream"] + REG["Catalog registry
published SKUs · PlanTier taxonomy · meteringUnit"] + end + subgraph s2["Slice 2 — Plan Definition"] + CSV["CycleShapeValidator"] + CMP["CompositionValidator"] + PHG["PhaseGraph"] + DSC["DescriptorSet"] + end + FND["Foundation (Slice 1)
ValidationPipeline · ReadModelProjector · EventOutbox"] + REG --> s2 + CSV --> FND + CMP --> FND + PHG --> FND + DSC --> FND +``` + +**Consumed:** published `skuId` + SKU `PlanTier` + `meteringUnit` declarations (registry). +**Produced:** the plan-shape portion of the read model (cycle, frequency metadata, phase map + +`displayTrialDays`, add-on rules, descriptor set — `quantitySource` is persisted/validated by +Slice 3), validated fail-closed at publish. + +## 2. Actor Flows (CDSL) + +### Author a Plan + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-plan-author` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager`, `cpt-cf-bss-pricing-actor-product-manager` + +**Success Scenarios**: +- A draft Plan is created against a **published** SKU with a billing cycle from the §17.1 matrix; add-on rules, phases, and descriptors attach incrementally in `draft` +- A recurring plan persists `frequency` (`monthly|quarterly|semiannual|annual|customEveryN{Days|Months}(n)`) as metadata + +**Error Scenarios**: +- Draft/unknown parent SKU → `SKU_NOT_PUBLISHED` (422) +- Non-positive or over-cap custom interval `n` → `INVALID_CUSTOM_INTERVAL` (422) +- Stale ETag → conflict (Foundation optimistic concurrency) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans (draft; idempotency key honored) - `inst-pa-create` +2. [ ] - `p1` - Validate the parent `skuId` is **published** in the registry read model - `inst-pa-sku` +3. [ ] - `p1` - Persist cycle + frequency metadata (`n` validated > 0 and ≤ cap, P1) - `inst-pa-cycle` +4. [ ] - `p1` - Attach add-on rules / phases / descriptors via PATCH while `draft` - `inst-pa-attach` +5. [ ] - `p1` - **RETURN** 201 (draft plan, ETag); `PlanCreated` emitted by the Foundation outbox - `inst-pa-return` + +### Publish a Plan + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-plan-publish` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` (approval per governance slice) + +**Success Scenarios**: +- Publish runs the Foundation pipeline with this slice's registered rules; on success the plan shape freezes into the read model and `PlanPublished` carries the pending version ref + +**Error Scenarios**: +- Any §17.1/§17.3 violation → 422 with the enumerated validation report (fail-closed; no event, no warm) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans/{planId}/publish - `inst-pp-api` +2. [ ] - `p1` - Foundation `ValidationPipeline` executes `CycleShapeValidator` + `CompositionValidator` + `PhaseGraph` + `DescriptorSet` rules (this slice) alongside Slice-3 price rules - `inst-pp-validate` +3. [ ] - `p1` - On success: Foundation freezes the shape into the read model + snapshot, emits the frozen events, requests `CatalogVersion` ([`01-foundation.md`](./01-foundation.md) §4.2 steps 3–5) - `inst-pp-freeze` +4. [ ] - `p1` - **RETURN** 202 (publish accepted / pending approval) or 422 (validation report) - `inst-pp-return` + +## 3. Processes / Business Logic (CDSL) + +### Billing-Cycle Shape Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-cycle-shape` + +**Input**: a draft plan + its price rows (rows authored via Slice 3) +**Output**: pass, or enumerated fail-closed violations + +**Steps**: +1. [ ] - `p1` - **One-time**: exactly one `one_time` base row **per sold `(currency, region)`**; optional purchase qty min/max (`purchase_min_qty ≤ purchase_max_qty` when both set) + availability dates (a past `availableFrom` is rejected — the Slice 5 historical-import path is the only sanctioned backdating); recurring-only add-ons rejected - `inst-cs-onetime` +2. [ ] - `p1` - **Recurring**: ≥ 1 recurring base row per sold `(currency, region)`; `billingTiming` REQUIRED on every recurring row; frequency metadata present; optional `one_time_setup` row allowed - `inst-cs-recurring` +3. [ ] - `p1` - **Usage-based**: parent SKU `meteringUnit` required; `billingGranularity` on **all** usage rows; `tierAggregationWindow` when tiered - `inst-cs-usage` +4. [ ] - `p1` - **Hybrid**: BOTH ≥ 1 recurring **and** ≥ 1 usage row on the same `planId` (distinct `chargeKind` keys); missing either part fails publish; optional `one_time_setup` allowed - `inst-cs-hybrid` +5. [ ] - `p1` - **Custom frequency**: `customEveryN Days(n)` MUST anchor `subscription_start`; `customEveryN Months(n)` MAY anchor `subscription_start` or `calendar_month` with month-end clamp + preserved anchor day (P2, D-20); non-positive/over-cap `n` fails - `inst-cs-customfreq` +6. [ ] - `p1` - **Setup row**: `chargeKind=one_time_setup` allowed only on recurring/hybrid plans; validated as one-time — no recurrence, no `billingTiming`, no tier fields; first-class row (participates in approval/snapshot/preview), never a synthetic add-on SKU - `inst-cs-setup` +7. [ ] - `p1` - **Setup charge timing (normative):** the setup row charges **once per subscription lifetime** — at activation, or for a plan with a `trial` phase at entry into the **first non-trial phase** (trial conversion; a cancelled trial is never charged setup). A plan change or `PlanLink` migration **never re-charges** the target plan's setup row (Slice 11 honors this in the migration contract). The timing is published in the read model for Subscriptions/Billing - `inst-cs-setup-timing` + +### Plan Composition Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-composition` + +**Input**: a draft plan (PlanTier, meter mapping, add-on rules) +**Output**: pass, or enumerated fail-closed violations + +**Steps**: +1. [ ] - `p1` - **PlanTier**: declared before publish (optional at draft); MUST equal the parent SKU's `PlanTier` unless an **explicit, audited override** is declared (P3, no silent divergence); the effective tier lands in the read model - `inst-cmp-plantier` +1a. [ ] - `p1` - **PlanTier drift after publish:** the equality check at publish is not enough — the registry can change the SKU's tier later. The catalog consumes the registry's SKU-tier-change signal and flags every affected **published** plan `tier_divergent` in the read model (+ the `pricing.plan.tier_divergent` alarm); remediation is a re-publish (re-validating equality) or an explicit audited override. Downstream consumers keep resolving the frozen published tier — the flag is a remediation signal, never a silent retro-change (part of the registry joint contract, PRD §15) - `inst-cmp-tier-drift` +2. [ ] - `p1` - **Meter injectivity**: each usage plan revision maps **exactly one** `meteringUnit` — one priced line per `(meter, dimensionKey)` **per scope-key slice** (`currency`/`region`/`priceOverlay`/`phase`/`priceEligibility` legitimately multiply rows); ambiguity fails publish. Multi-meter offerings route to a derived (composite) meter (Slice 10) or separate single-meter SKUs composed via bundle/add-ons (Slice 8) - `inst-cmp-injective` +3. [ ] - `p1` - **Add-on rules**: add-on SKUs published + compatible with the base SKU; the dependency/conflict **edges are plan-authored** (D-16): each rule row MAY declare `depends_on` / `conflicts_with` sets referencing **other add-ons of the same plan's set** (an edge pointing outside the set fails; conflicts are normalized symmetric); dependency **cycles** fail publish (graph walk over `depends_on`), conflicting pairs fail when both marked required; a required add-on has `maxQty ≥ 1`; an optional price-override reference persists on the plan snapshot - `inst-cmp-addons` +4. [ ] - `p1` - **Override home (normative):** `price_override_ref` resolves to a **published `priceId` on a plan of the add-on SKU itself** (an alternative row authored there — it is a normal price row with its own scope key, windows, and coverage). Publish of the base plan validates the reference exists, is published, and covers every `(currency, region)` the base plan sells (Slice 4 case i); the resolved mapping freezes into the base plan's `pricingSnapshotRef`. No override price is ever authored as a detached number on the base plan - `inst-cmp-override-home` + +### Phase Schedule Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-phases` + +**Input**: a phased plan's `PhaseGraph` +**Output**: a published phase→price map, ordering, successors, `displayTrialDays` + +**Steps**: +1. [ ] - `p1` - Each phase id maps to its price-row references (rows carry the `phase` scope-key axis); ordering persisted - `inst-ph-map` +2. [ ] - `p1` - `convertsToPhaseId` edges validated: no dangling target, no cycle; **exactly one terminal phase** (`evergreen`, no successor) - `inst-ph-graph` +3. [ ] - `p1` - **Phase duration (normative):** every **non-terminal** phase MUST author `phaseDurationDays > 0` — `convertsToPhaseId` says *where* a phase converts, the duration says *when*; a non-terminal phase without a duration (or a terminal phase with one) fails publish. Subscriptions enforces phase runtime from these published durations (single source) - `inst-ph-duration` +3a. [ ] - `p1` - **Phase coverage (D-15):** every phase id MUST be referenced by ≥ 1 published **recurring** price row for every `(currency, region)` the plan sells — an uncovered phase fails publish (`PHASE_UNCOVERED`): a phase conversion must never resolve to nothing (the row-based Slice 7 coverage check cannot see a phase that has no rows at all) - `inst-ph-coverage` +3b. [ ] - `p1` - **Usage rows are phase-invariant by default (D-15):** one usage row (on the **terminal `phase_id`** — D-19) covers **all** phases; an explicit phase-scoped usage row overrides it **for its phase** (phase-specific wins — a published resolution rule of the same class as most-specific-wins eligibility, adopted verbatim by Tariffs; joint fixture). Free trial usage = an explicit trial-phase usage row at 0 — never a silent default - `inst-ph-usage-invariant` +4. [ ] - `p1` - A `trial` phase publishes `displayTrialDays` = its `phaseDurationDays` (the PRD-named alias for preview/quoting; one value, two projections) - `inst-ph-trial` +5. [ ] - `p1` - **Axis typing (D-19):** the `phase` axis is always a `phase_id`. Every plan gets a terminal phase row — authored (phased plans) or **auto-created implicit** (kind `evergreen`; non-phased/one-time plans) at plan creation; non-phased/one-time/setup rows carry that terminal `phase_id` (Foundation §4.1 defaults). The literal `evergreen` is a phase *kind*, never an axis value - `inst-ph-default` + +### Billing Descriptor Completeness + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-descriptors` + +**Input**: the plan's `DescriptorSet` +**Output**: pass (set frozen into `CatalogVersion`), or a report listing missing fields + +**Steps**: +1. [ ] - `p1` - Required per manifest §4.1: invoice line template, tax category, GL code, composition/itemization rules; publish blocks on any missing field with the field named in the report - `inst-ds-required` +2. [ ] - `p1` - The frozen set MUST be sufficient for Billing/ERP to post without re-querying mutable rows; the minimum field list is confirmed with Billing (P5) and the validator's required-set is config-extensible without a schema change - `inst-ds-sufficient` + +## 4. States (CDSL) + +### Plan Lifecycle State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-plan-lifecycle` + +**States**: draft, published, retired +**Initial State**: draft (mutable, deletable; optional `PlanTier`) + +**Transitions**: +1. [ ] - `p1` - **FROM** draft **TO** published **WHEN** the Foundation pipeline passes (this slice's rules included) and approval (governance slice) completes; shape freezes into the read model - `inst-pl-publish` +2. [ ] - `p1` - **FROM** published **TO** retired **WHEN** the lifecycle slice retires the plan (Slice 11; blocks new subscriptions, preserves snapshots) - `inst-pl-retire` +3. [ ] - `p1` - Published plans never return to draft; a change is a **new revision** through the Foundation's versioning (append-only) - `inst-pl-norollback` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/pricing/plans` | Create a draft plan | client idempotency key | +| `PATCH` | `/v1/pricing/plans/{planId}` | Update draft shape (cycle, phases, add-ons, descriptors) | ETag | +| `POST` | `/v1/pricing/plans/{planId}/publish` | Run fail-closed validation + submit for approval/publish | per plan revision | +| `GET` | `/v1/pricing/plans/{planId}` | Read (draft for authors; published via read model) | — | + +**Problem responses (RFC 9457):** `SKU_NOT_PUBLISHED` (422), `INVALID_CUSTOM_INTERVAL` (422), +`HYBRID_INCOMPLETE` (422), `PLANTIER_MISSING`/`PLANTIER_DIVERGENT` (422), `METER_AMBIGUOUS` +(422), `ADDON_CYCLE`/`ADDON_INCOMPATIBLE` (422), `ADDON_OVERRIDE_UNRESOLVED` (422 — +`price_override_ref` unpublished or not covering a sold `(currency, region)`), +`PHASE_GRAPH_INVALID` (422), `PHASE_DURATION_INVALID` (422 — non-terminal phase without +`phaseDurationDays`, or a terminal phase with one), `PHASE_UNCOVERED` (422 — a phase with no +covering recurring row for a sold `(currency, region)`, D-15), `SETUP_ROW_INVALID` (422 — setup row on a +one-time plan, or carrying recurrence/`billingTiming`/tier fields), +`PURCHASE_QTY_RANGE_INVALID` (422 — `purchase_min_qty > purchase_max_qty`), +`AVAILABLE_FROM_IN_PAST` (422 — outside the historical-import path), +`DESCRIPTOR_INCOMPLETE` (422). Concrete error taxonomy is refined at implementation; names +follow the fail-closed report contract (every violation enumerated). + +## 6. Data Model + +This slice extends the Foundation-owned `pricing_plan` with shape tables (tenant-scoped, SecureORM; +draft rows mutable, published rows append-only per Foundation §4.3): + +**`pricing_plan` (Foundation-owned; Slice-2 columns)** — columns physically declared in +Foundation §3.7 with capability semantics owned here: `billing_cycle` +(`one_time|recurring|usage|hybrid`), `frequency` + `custom_interval_n`/`custom_interval_unit`, +`plan_tier`, `plan_tier_override` (bool, audited), `available_from`/`available_to`, +`purchase_min_qty`/`purchase_max_qty` (nullable; one-time plans). + +**`pricing_plan_phase`** (PK `phase_id`; FK `plan_id`). Every plan holds ≥ 1 row: phased plans author theirs; non-phased/one-time plans get one **implicit terminal row** (kind `evergreen`) auto-created at plan creation — the default `phase` axis value (D-19): + +| Column | Type | Notes | +|--------|------|-------| +| `phase_id` | `uuid` | PK; referenced by the `phase` scope-key axis | +| `plan_id` | `uuid` | FK | +| `plan_revision` | `int` | the plan revision this phase row belongs to | +| `kind` | `enum` | `trial \| intro \| evergreen` | +| `ordinal` | `int` | phase ordering | +| `converts_to_phase_id` | `uuid` | successor; NULL only on the terminal phase | +| `phase_duration_days` | `int` | REQUIRED > 0 on non-terminal phases; forbidden on the terminal phase | +| `display_trial_days` | `int` | trial phases: projection of `phase_duration_days` under the PRD name (preview + runtime single source) | + +**`pricing_plan_addon_rule`** (FK `plan_id`): `addon_sku_id`, `required` (bool), `min_qty`/`max_qty`/`step_qty`, +`price_override_ref` (nullable), `depends_on_addon_sku_id[]` / `conflicts_with_addon_sku_id[]` +(D-16 — values MUST be members of the same plan's add-on set; conflicts stored normalized +symmetric). Cycle/conflict checks run over these plan-authored edges at publish. + +**`pricing_plan_descriptor_set`** (FK `plan_id`): `invoice_line_template`, `tax_category`, `gl_code`, +`itemization_rule` (+ config-extensible required-field registry, P5). + +Key constraints: at most one terminal phase per plan revision (partial unique on +`(plan_id, plan_revision) WHERE converts_to_phase_id IS NULL`; **existence** of exactly one +terminal phase is the PhaseGraph pipeline rule — an index cannot enforce the ≥ 1 half); +`custom_interval_n > 0` CHECK; `purchase_min_qty <= purchase_max_qty` CHECK; add-on rule +`max_qty >= 1 WHERE required`; meter injectivity enforced as a unique `(plan_revision, +currency, region, price_overlay, phase, price_eligibility, meter, dimension_key)` over active +usage rows — one priced line per `(meter, dimensionKey)` **per scope-key slice** +(`meter`/`dimension_key` are Slice-3 usage-row columns; the CompositionValidator restates the +same rule in the pipeline). + +## 7. Events & Alarms + +Alarm: `pricing.plan.tier_divergent` (Warn — a registry SKU-tier change diverged from a published plan's frozen tier; remediation = re-publish or audited override). No new event names: this slice rides the Foundation's frozen set — `PlanCreated`, +`PlanUpdated` on authoring; `PlanPublished` on successful publish (shape included in the +warmed read model). Validation failures are synchronous 422 reports, not events. Alarms: +publish-blocked-by-validation is surfaced as an authoring outcome + the validation-catch-rate +metric (§10), not an operational alarm. + +## 8. Definitions of Done + +### Billing-Cycle Matrix + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-billing-cycles` + +The system **MUST** support authoring and fail-closed publish of the §17.1 cycle matrix — +one-time, recurring (incl. `customEveryN{Days|Months}(n)` with `n > 0` ≤ cap and +`subscription_start` anchoring for custom-days), usage-based, and hybrid (both parts required) +— with the optional first-class `one_time_setup` row validated as one-time, and its charge +semantics (once per subscription lifetime; at activation, or at trial conversion for trialed +plans; never re-charged on plan change/`PlanLink` migration) projected into the read model. + +**Implements**: `cpt-cf-bss-pricing-algo-cycle-shape`, `cpt-cf-bss-pricing-flow-plan-author` + +**Touches**: +- API: `POST/PATCH /v1/pricing/plans`, `POST /v1/pricing/plans/{planId}/publish` +- DB: `pricing_plan` (cycle/frequency columns) +- Entities: `CycleShapeValidator` + +### Plan Composition & PlanTier + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-composition` + +The system **MUST** enforce at publish: parent SKU published; `PlanTier` present and equal to +the SKU's unless an explicit audited override; exactly one `meteringUnit` per usage plan +revision (per-row `(meter, dimensionKey)` injectivity); add-on SKUs published + compatible, +no conflicting pairs, no dependency cycles, required add-ons `maxQty ≥ 1`; add-on +`price_override_ref`s published and covering every sold `(currency, region)`. A post-publish +registry SKU-tier change **MUST** flag affected published plans `tier_divergent` (+ the +`pricing.plan.tier_divergent` alarm; remediation = re-publish or audited override). + +**Implements**: `cpt-cf-bss-pricing-algo-composition` + +**Touches**: +- DB: `pricing_plan`, `pricing_plan_addon_rule` +- Entities: `CompositionValidator` + +### Phases + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-phases` + +The system **MUST** publish, for a phased plan, the phase→price map, ordering, and +`convertsToPhaseId` successors — rejecting dangling/cyclic successors, requiring exactly one +terminal phase, `phaseDurationDays > 0` on every non-terminal phase (publish fails on a +non-terminal phase without a duration), and **recurring coverage of every phase per sold +`(currency, region)`** (`PHASE_UNCOVERED` otherwise; usage rows are phase-invariant by +default with phase-specific override — D-15), and publishing `displayTrialDays` on `trial` +phases as the single source for Subscriptions runtime. + +**Implements**: `cpt-cf-bss-pricing-algo-phases` + +**Touches**: +- DB: `pricing_plan_phase` +- Entities: `PhaseGraph` + +### Descriptors + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-descriptors` + +The system **MUST NOT** publish without the complete billing descriptor set (manifest §4.1); +the validation report **MUST** name each missing field; the frozen set **MUST** be sufficient +for Billing/ERP posting without re-querying mutable rows. + +**Implements**: `cpt-cf-bss-pricing-algo-descriptors` + +**Touches**: +- DB: `pricing_plan_descriptor_set` +- Entities: `DescriptorSet` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture (levels + mocking inherited). + +Unit: + +- [ ] Cycle-matrix validation per §17.1 (each cycle's required/forbidden fields); custom-`n` bounds + anchoring; hybrid completeness; setup-row one-time constraints; one-time purchase-qty range (`minQty > maxQty` rejected) + past-`availableFrom` rejection; PlanTier equality/override; add-on cycle detection over plan-authored `depends_on` edges (an edge outside the plan's add-on set fails; conflict symmetry normalized; two required conflicting add-ons fail); add-on override-home resolution (unpublished ref or uncovered `(currency, region)` fails); phase-graph acyclicity + single terminal + non-terminal duration required; descriptor required-set + +Integration (testcontainers): + +- [ ] A hybrid plan (recurring + usage + setup) publishes; removing either mandatory part fails publish with the part named +- [ ] A plan against a draft SKU fails publish (`SKU_NOT_PUBLISHED`) +- [ ] `customEveryN Days(30)` with `calendar_month` anchor fails publish +- [ ] A phased plan trial→intro→evergreen publishes its phase map + `displayTrialDays`; a cyclic `convertsToPhaseId` fails +- [ ] The same plan with **zero intro-phase recurring rows** fails publish (`PHASE_UNCOVERED`, naming the phase + market); a single phase-invariant usage row satisfies all phases, and an explicit trial-phase usage row at 0 wins over it for the trial phase +- [ ] A published plan's shape change creates a new revision (append-only), never mutates in place +- [ ] The published read model exposes the setup row's charge-timing semantics (once per lifetime; trial-conversion; never re-charged on migration) +- [ ] A registry SKU-tier change flags the affected published plan `tier_divergent` and raises the alarm; the frozen tier keeps resolving + +API: + +- [ ] RFC 9457 mapping for the §5 problem codes; the 422 validation report enumerates **all** violations, not the first + +## 10. Non-Functional Considerations + +- **Performance**: validation is authoring-path (not rating-path); the full pipeline on a worst-case plan (max phases/add-ons/currencies) must fit the publish interaction budget; plan/tier size caps are a **provisional NFR** to ratify before Design lock ([`../PRD.md`](../PRD.md) §14). +- **Observability / metrics**: `pricing_publish_validation_failures_total{rule}`, `pricing_publish_total`, validation-catch-rate (goal: 100% of known-invalid configs blocked — PRD §1.3 success metric). +- **Security & AuthZ**: authoring requires the catalog-authoring scope; PlanTier override and descriptor changes are audited mutations (governance slice owns the trail). +- **Risks & open items**: minimum descriptor field set open with Billing (P5); localization owner for plan-owned display fields open (P4); upstream SKU retirement/unpublish joint contract open — a published plan referencing a retired SKU needs the registry's remediation signal (PRD §15). If the registry later declares **intrinsic SKU compatibility** metadata, it becomes an additional validation input (plan-authored edges checked against it) — additive, no migration (D-16). diff --git a/gears/bss/pricing/docs/design/03-price-structure.md b/gears/bss/pricing/docs/design/03-price-structure.md new file mode 100644 index 000000000..156d34f0d --- /dev/null +++ b/gears/bss/pricing/docs/design/03-price-structure.md @@ -0,0 +1,393 @@ + + + +# DESIGN — Price Structure & Model Kinds (Slice 3) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author Price Rows](#author-price-rows) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Model-Kind Validation](#model-kind-validation) + - [Tier-Band Validation](#tier-band-validation) + - [Package Pricing Validation](#package-pricing-validation) + - [Conformance Fixture Gate](#conformance-fixture-gate) +- [4. States (CDSL)](#4-states-cdsl) + - [Price Row State Machine](#price-row-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Explicit Model Kind](#explicit-model-kind) + - [Tier Bands](#tier-bands) + - [Package Pricing](#package-pricing) + - [Conformance Gate](#conformance-gate) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **structure of a price row**: the explicit `modelKind` +(`flat | per_unit | graduated | volume | package`), tier-band validation under the +`[fromQty, toQty)` convention, package (block) pricing fields, the usage evaluation-policy +placement rules (`tierAggregationWindow`, `billingGranularity`), and the **joint +golden-conformance-fixture gate** that blocks publish of any `modelKind` Tariffs cannot +provably evaluate. Rows live on the Foundation's canonical scope key and publish through the +Foundation pipeline; the **math is never computed here** — Tariffs applies the formula per +the §17.2 conformance mapping. + +**Traces to**: `cpt-cf-bss-pricing-fr-model-kind`, `cpt-cf-bss-pricing-fr-tier-validation`, +`cpt-cf-bss-pricing-fr-package-pricing`, `cpt-cf-bss-pricing-fr-model-kind-conformance`, +`cpt-cf-bss-pricing-fr-price-amount-validation`, `cpt-cf-bss-pricing-fr-per-seat` + +### 1.2 Purpose + +Guarantee that every published price row is **unambiguously evaluable**: the model kind is +explicit and frozen (no rating-time default), tier bands cannot overlap, gap, or close the top (any quantity is always rateable — D-17), package fields are structurally exclusive with tier fields, and no kind +publishes without a version-controlled joint fixture proving catalog↔Tariffs agreement — +eliminating the class of silent mispricing bugs at band edges and kind mismatches. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Authors amounts, model kinds, tier bands | +| `cpt-cf-bss-pricing-actor-rating` | Consumes `modelKind` + bands + evaluation policy; resolves policy fields deterministically; co-owns golden fixtures (consolidated gear — rating ADR-0002) | +| `cpt-cf-bss-pricing-actor-subscriptions` | Supplies `subscription_seat_count` for `per_unit` rows at rating time | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.2, §17.1 (structure kinds), §17.2 (conformance mapping), §17.4 (validation rules) +- **Design**: [01-foundation.md](./01-foundation.md) — scope key (§4.1), publish contract (§4.2), money constraint +- **Dependencies**: Foundation (Slice 1); co-required with plan-definition (Slice 2) — the cycle shape decides which `chargeKind` rows a plan needs; reserved-capacity attributes extend the usage row in Slice 10. + +### 1.5 Scope + +**In scope**: `modelKind` enum + explicitness; `[fromQty, toQty)` band validation (ascending, +non-overlapping, contiguous, always-open top — D-17); `package` fields + structural exclusivity; +`per_unit` `quantitySource` persistence; evaluation-policy placement (usage-only fields); +amount/currency/precision validation (delegating the shared checks to the Foundation); +the conformance-fixture publish gate. + +**Out of scope**: formula evaluation, graduated-vs-volume math, round-up math (Tariffs); +tier resets / `Q` derivation semantics (Tariffs; catalog persists the window enum only); +reserved-rate attributes and derived meters (Slice 10); price overlays/overlays (Slice 9); +windows (Slice 7). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-3-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| Q1 | Band convention | Half-open `[fromQty, toQty)`; the top band is **always open** (`toQty = null`) — a closed top fails publish (`TIER_TOP_CLOSED`, D-17): quantity capping is owned by entitlement **quotas** (Subscriptions enforces), per-period fee caps are Tariffs Future | PRD §6.2; D-17 | +| Q2 | Aggregation derivation | `tierAggregationWindow` defines **when** `Q` resets; derivation is the row's authorable `aggregationFunction ∈ {sum (default), peak, time_weighted}` (**D-44**, launch): non-`sum` folds gauge samples per `aggregationGranularity {hour, day}` granule (max / step-integral) and `Q` = Σ granule folds — **additive**, so band math, supersession continuity, and `bandOffsetQ` are untouched; frozen in `pricingSnapshotRef`; `last`/`unique` Future; no composite co-occurrence at launch | PRD §1.4; D-44; rating T-D-17 | +| Q3 | Volume semantics | Catalog `volume` maps to Tariffs **Variant A only** (single rate on total `Q`); Variant B (per-tier block fee) is dropped and not authorable | PRD §17.2 | +| Q4 | Fixture repo | Golden fixtures are version-controlled in a shared catalog+Tariffs repo **before code**; the publish gate reads a per-tenant-independent fixture registry | PRD §13 | +| Q5 | Zero amounts | `0` is a valid amount (free tier, `trial`/`intro`, first graduated band); negatives rejected (typed credit rows are Future) | PRD §17.4 | +| Q6 | Included allowance | Authored `includedAllowance {quantity, rolloverPolicy}` (D-45) **compiles at publish**: `none` → `$0` first band `[0, N)` + frozen first-class marker (band math unchanged); `carry` → D-43 per-period promotional grant (Billing executes; no catalog balance). `sum` rows only; never combined with an authored `$0` first band (double-free, publish-blocked) | PRD §1.4/§6.10; D-45 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary; inherits Foundation mechanics. Not restated. + +Design-introduced names (Slice 3): + +| Name | Meaning | +|------|---------| +| `ModelKindValidator` | Registered rules: explicit kind, kind-specific required/forbidden fields | +| `TierBandValidator` | Registered rules: ordering, non-overlap, contiguity, top-band policy under Q1 | +| `PackageValidator` | Registered rules: `packageSize`/`packagePrice` presence + structural exclusivity with tier-band fields | +| `FixtureGate` | The publish-time check that the row's `modelKind` (and reservation variant) has a green joint golden fixture | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s3["Slice 3 — Price Structure"] + MKV["ModelKindValidator"] + TBV["TierBandValidator"] + PKV["PackageValidator"] + FXG["FixtureGate"] + end + FIX[("Joint golden fixtures
(shared catalog + Tariffs repo)")] + FND["Foundation (Slice 1)
ScopeKey · ValidationPipeline · ReadModelProjector"] + TRF["Tariffs / PLAL
formula evaluation (§17.2)"] + FIX --> FXG + MKV --> FND + TBV --> FND + PKV --> FND + FXG --> FND + FND --> TRF +``` + +**Consumed:** the fixture registry (Q4). **Produced:** the price-structure portion of the read +model — `modelKind`, ordered bands, `packageSize`/`packagePrice`, +`quantitySource`, evaluation-policy fields — frozen in `pricingSnapshotRef` for Tariffs. + +## 2. Actor Flows (CDSL) + +### Author Price Rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-price-author` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Success Scenarios**: +- A draft price row is authored on the canonical scope key with an explicit `modelKind` and its kind-specific fields; `PriceCreated` emits per row +- Tiered rows carry ordered `[fromQty, toQty)` bands; usage rows carry `billingGranularity` (+ `tierAggregationWindow` when tiered); a tiered row MAY additionally carry the Slice-10 `tierQualificationWindow` primitive (`current` \| `trailing_month`, D-40 / [`design/10-advanced-primitives.md`](./10-advanced-primitives.md)) — a **third, orthogonal** window that qualifies the rate tier from the prior period and locks it, distinct from `tierAggregationWindow` (counter reset) and `billingGranularity` (billing cadence) + +**Error Scenarios**: +- Duplicate active scope key without supersession → `DUPLICATE_SCOPE_KEY` (409, Foundation) +- Tiered row without a `modelKind` → `MODEL_KIND_MISSING` (422) +- Precision above the currency's ISO 4217 minor unit → `PRECISION_EXCEEDED` (422, Foundation) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans/{planId}/prices (draft row; idempotency key honored; scope-key axes defaulted by the Foundation `ScopeKey`) - `inst-pr-create` +2. [ ] - `p1` - Persist `modelKind` + kind-specific fields (bands / package / `quantitySource`); shared amount/currency/precision checks run in the Foundation - `inst-pr-fields` +3. [ ] - `p1` - PATCH while `draft`; published rows are append-only (change = supersession, Foundation §4.3) - `inst-pr-mutate` +4. [ ] - `p1` - **RETURN** 201 (draft row, ETag). **Validation split (D-21):** all **row-local** checks run at save *and* re-run at publish — model-kind shape (explicit kind, kind×chargeKind matrix, required/forbidden fields), band-set geometry (ordering, overlap, gap/contiguity, zero-width, open top), precision, evaluation-policy placement, scope-key duplication (PRD AC #12's "save/publish MUST fail" for band geometry is satisfied at save). **Aggregate/cross-entity** checks run at publish only: fixtures, window coverage, phase coverage, hybrid completeness, meter injectivity - `inst-pr-return` + +## 3. Processes / Business Logic (CDSL) + +### Model-Kind Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-model-kind` + +**Input**: a draft price row at publish +**Output**: pass, or enumerated fail-closed violations + +**Steps**: +1. [ ] - `p1` - `modelKind ∈ {flat, per_unit, graduated, volume, package}` MUST be explicit; a tiered row with no kind MUST NOT publish ("tiered (unspecified)" is not publishable, §17.1); no implicit default exists at rating time - `inst-mk-explicit` +2. [ ] - `p1` - **Kind-specific required fields**: `per_unit` → unit price + `quantitySource` (`subscription_seat_count | manual`, and the fixed quantity for `manual`); `graduated`/`volume` → ≥ 1 tier band; `package` → `packageSize`/`packagePrice`; `flat` → single amount - `inst-mk-required` +3. [ ] - `p1` - **Kind-specific forbidden fields**: tier-band fields absent on `flat`/`per_unit`/`package`; `tierAggregationWindow`/`billingGranularity` are **usage-row only** — presence on `flat` non-usage or `per_unit` rows fails publish (§17.4 evaluation-policy placement) - `inst-mk-forbidden` +3a. [ ] - `p1` - **Kind×chargeKind matrix (D-18)**: `graduated`/`volume`/`package` are valid **only on `chargeKind = usage`** rows — a tiered/package model on a `recurring`/`one_time`/`one_time_setup` row fails publish (`MODEL_KIND_CHARGEKIND_MISMATCH`): the tier machinery presupposes a metered quantity stream, and no `Q` semantics exist for non-usage rows. Tiered per-seat pricing (bands over seat count on recurring rows) is Future scope (§17.8) - `inst-mk-chargekind` +4. [ ] - `p1` - The catalog computes **no** charge: kinds are flags Tariffs maps to formulas one-to-one per §17.2; catalog `volume` = Variant A only (Q3) - `inst-mk-nocompute` + +### Tier-Band Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-tier-bands` + +**Input**: a `graduated`/`volume` row's band set +**Output**: an ordered, gapless, non-overlapping band set in the read model (+ top-band policy) + +**Steps**: +1. [ ] - `p1` - Bands sorted ascending by `fromQty`; any overlap fails; any gap fails (contiguity under `[fromQty, toQty)`: next `fromQty` = previous `toQty`); each band MUST satisfy `toQty > fromQty` when `toQty` is non-null (`TIER_BAND_EMPTY`); an advisory (non-blocking) warning is emitted when any band's effective unit price exceeds the previous band's (non-volume-discount pattern) — carried in the Foundation validation report's `warnings[]` channel - `inst-tb-order` +2. [ ] - `p1` - First band starts at the row's quantity origin (`fromQty = 0`); a `$0` first band is valid (Q5 — the included-allowance representation) - `inst-tb-first` +3. [ ] - `p1` - **Top band is always open (D-17)**: `toQty = null` REQUIRED on the top band; a closed top fails publish (`TIER_TOP_CLOSED`) — "price undefined above X" is never the commercial intent: quantity capping is an entitlement **quota** (grant set; Subscriptions enforces), per-period fee caps are Tariffs Future (§17.8), and a different price above X is simply another band. Any quantity is therefore always rateable on a tiered row — "sold but unrateable" is impossible by construction - `inst-tb-top` +4. [ ] - `p1` - Tiered usage rows MUST carry `tierAggregationWindow` (`calendar_month | invoice_period | subscription_lifetime | per_event`); derivation is window-sum only at launch (Q2) - `inst-tb-window` +5. [ ] - `p1` - **Band units (normative):** `fromQty`/`toQty` are expressed in **billable units after `billingGranularity` quantization** (e.g. `per_hour` → band quantities are hours, never raw seconds); the read model documents the unit so catalog and Tariffs cannot diverge on it - `inst-tb-units` +6. [ ] - `p1` - **Window continuity across supersession (normative):** the tier counter `Q` is derived per **`(subscription, meter, window)`** — it belongs to the subscription's usage history, **not** to a price-row version. Superseding a row (new bands, new price) does **NOT** reset an in-window counter, and `subscription_lifetime` `Q` in particular survives every supersession/versioning; the new row's bands are simply applied to the continued `Q`. Requires its own joint golden fixture (a supersession mid-window scenario) in the Slice 3 conformance registry - `inst-tb-window-continuity` + +### Package Pricing Validation + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-package` + +**Input**: a `modelKind=package` row +**Output**: `packageSize`/`packagePrice` in the read model; structural exclusivity enforced + +**Steps**: +1. [ ] - `p2` - `packageSize > 0` (units per block) and `packagePrice ≥ 0` (per block) MUST be present; tier-band fields MUST be absent; publish rejects otherwise - `inst-pk-fields` +2. [ ] - `p2` - The round-up math (`blocks = ceil(used / packageSize)`, `charge = blocks × packagePrice`) is **Tariffs-owned**; the read model exposes the two fields only - `inst-pk-math` + +### Conformance Fixture Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-fixture-gate` + +**Input**: a publishing row's `modelKind` (and, from Slice 10, its reservation variant) +**Output**: pass, or a publish block naming the missing fixture + +**Steps**: +1. [ ] - `p1` - The `FixtureGate` resolves the row's kind against the **joint golden conformance fixture registry** (Q4); publish of any `modelKind` lacking a green joint fixture is **blocked** - `inst-fx-gate` +2. [ ] - `p1` - `package` (repeating-block) and `per_unit` (external-quantity) each require their own joint fixture before first publish; the reservation variant of a usage row requires its own fixture (Slice 10 registers it into this gate) - `inst-fx-kinds` +3. [ ] - `p1` - The §17.2 table is the **single kind-to-formula source of truth**; catalog and Tariffs MUST NOT diverge from it — the gate is the enforcement point on the catalog side - `inst-fx-sot` + +## 4. States (CDSL) + +### Price Row State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-price-row` + +**States**: draft, published, superseded +**Initial State**: draft (mutable, deletable) + +**Transitions**: +1. [ ] - `p1` - **FROM** draft **TO** published **WHEN** the Foundation pipeline (incl. this slice's validators + the `FixtureGate`) passes and the publish commits; the row becomes append-only - `inst-ps-publish` +2. [ ] - `p1` - **FROM** published **TO** superseded **WHEN** a supersession within the same canonical scope key closes this row's window and opens the successor's (Foundation §4.3; no in-place mutation, no overlap) - `inst-ps-supersede` +3. [ ] - `p1` - There is no deleted state for published rows; only never-published `draft` rows are deletable - `inst-ps-nodelete` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/pricing/plans/{planId}/prices` | Create a draft price row on the scope key | client idempotency key | +| `PATCH` | `/v1/pricing/plans/{planId}/prices/{priceId}` | Update a draft row | ETag | +| `DELETE` | `/v1/pricing/plans/{planId}/prices/{priceId}` | Delete a **draft** row (published rows: 409) | — | +| `GET` | `/v1/pricing/plans/{planId}/prices` | List rows (draft for authors; published via read model) | — | + +**Problem responses (RFC 9457):** `MODEL_KIND_MISSING` (422), `TIER_BANDS_OVERLAP` / +`TIER_BANDS_GAP` (422), `TIER_BAND_EMPTY` (422 — `toQty ≤ fromQty` on a non-open band), +`TIER_TOP_CLOSED` (422 — the top band must be open; capping belongs to quotas / per-period caps, D-17), `PACKAGE_FIELDS_INVALID` (422), +`EVAL_POLICY_MISPLACED` (422), `MODEL_KIND_CHARGEKIND_MISMATCH` (422 — `graduated`/`volume`/`package` on a non-usage row, D-18), `EVAL_POLICY_MISSING` (422 — `tierAggregationWindow` unset on a +tiered usage row, or `billingGranularity` unset on a usage row; the error references the +allowed values per the PRD Glossary), `QUANTITY_SOURCE_MISSING` (422), `FIXTURE_MISSING` (422), +`DUPLICATE_SCOPE_KEY` (409 — Foundation-owned, referenced here), `PRECISION_EXCEEDED` (422). +The publish-time report enumerates all violations. + +## 6. Data Model + +This slice populates the Foundation-owned `pricing_price` and owns `pricing_price_tier_band` (tenant-scoped, +SecureORM; published rows append-only per Foundation §4.3): + +**`pricing_price` (Foundation-owned; Slice-3 columns)**: + +| Column | Type | Notes | +|--------|------|-------| +| `model_kind` | `enum` | `flat \| per_unit \| graduated \| volume \| package`; NOT NULL on publish | +| `quantity_source` | `enum` | `subscription_seat_count \| manual`; required for `per_unit` | +| `manual_quantity` | `bigint` | required when `quantity_source = manual`; frozen in snapshot | +| `package_size` | `bigint` | `> 0`; `package` only | +| `package_price_minor` | `bigint` | `≥ 0`; `package` only | +| `tier_aggregation_window` | `enum` | `calendar_month \| invoice_period \| subscription_lifetime \| per_event`; tiered usage rows only | +| `billing_granularity` | `enum` | `per_second \| per_minute \| per_hour \| per_day \| whole_unit`; all usage rows | +| `meter` | `ref` | the published `meteringUnit` a usage row prices; feeds the Slice-2 injectivity rule | +| `dimension_key` | `text` | nullable dimension discriminator on the `(meter, dimensionKey)` line (Slice-2 injectivity) | + +**`pricing_price_tier_band`** (FK `price_id`; `graduated`/`volume` rows only): + +| Column | Type | Notes | +|--------|------|-------| +| `band_id` | `uuid` | PK | +| `price_id` | `uuid` | FK | +| `from_qty` | `bigint` | inclusive; ascending, contiguous | +| `to_qty` | `bigint` | exclusive; NULL = open top | +| `unit_price_minor` | `bigint` | `≥ 0` (`0` valid — Q5); unit prices only, no per-band flat fee (Q3) | + +**`pricing_conformance_fixture_registry`** (read-side of the shared fixture repo, Q4): `model_kind` / +`variant`, `fixture_ref`, `status` (`green | missing | stale`). The `FixtureGate` reads it at +publish. The `variant` axis also keys **cross-cutting scenario fixtures** (e.g. +`variant = supersession_continuity` on the tiered kinds, per `inst-tb-window-continuity`); +the continuity fixture **gates the first publish of any tiered usage kind** (alongside that +kind's own fixture) — ratified, D-22. + +Key constraints: `CHECK (package_size > 0)`; `CHECK (unit_price_minor >= 0)`; +`CHECK (to_qty IS NULL OR to_qty > from_qty)` (no zero-width bands); structural exclusivity — +band rows forbidden unless `model_kind IN ('graduated','volume')`, enforced by a trigger or a +composite FK on `(price_id, model_kind)` (cross-table, not expressible as a row CHECK); the +package-fields-forbidden-with-bands half is a row CHECK on `pricing_price`; per-row band +contiguity/non-overlap enforced by the `TierBandValidator` at publish (order-dependent, not +expressible as a row CHECK); unique `(price_id, from_qty)`. + +## 7. Events & Alarms + +No new event names: `PriceCreated` on row authoring, `PriceUpdated` on supersession +(Foundation frozen set). A publish blocked by the `FixtureGate` is a synchronous 422; a +**stale** fixture (registry drift after publish) raises the operational alarm +`pricing.conformance.fixture_stale` (Warn) — the mispricing-risk signal that the §17.2 +source-of-truth table and the fixture repo have diverged. + +## 8. Definitions of Done + +### Explicit Model Kind + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-model-kind` + +Every price row **MUST** persist an explicit `modelKind` with its kind-specific +required/forbidden field sets enforced at publish (per-unit `quantitySource`; usage-only +evaluation-policy placement); a tiered row without a kind **MUST NOT** publish; the catalog +computes no charge. + +**Implements**: `cpt-cf-bss-pricing-algo-model-kind`, `cpt-cf-bss-pricing-flow-price-author` + +**Touches**: +- API: `POST/PATCH /v1/pricing/plans/{planId}/prices` +- DB: `pricing_price` (model-kind columns) +- Entities: `ModelKindValidator` + +### Tier Bands + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-tier-bands` + +Tier bands **MUST** validate ascending, non-overlapping, contiguous under `[fromQty, toQty)` +with an **always-open top** (`toQty = null`; a closed top fails publish — `TIER_TOP_CLOSED`, +D-17: capping is owned by entitlement quotas / per-period caps) and no zero-width bands +(`toQty > fromQty`); tiered usage rows **MUST** carry `tierAggregationWindow`; band quantities are expressed in +billable units after `billingGranularity` quantization (the read model documents the unit). + +**Implements**: `cpt-cf-bss-pricing-algo-tier-bands` + +**Touches**: +- DB: `pricing_price_tier_band` +- Entities: `TierBandValidator` + +### Package Pricing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-package` + +A `package` row **MUST** persist `packageSize > 0` and `packagePrice ≥ 0` with tier-band +fields absent (publish rejects otherwise); the read model exposes the two fields and Tariffs +owns the round-up math. + +**Implements**: `cpt-cf-bss-pricing-algo-package` + +**Touches**: +- DB: `pricing_price` (package columns) +- Entities: `PackageValidator` + +### Conformance Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-conformance` + +Publish of any `modelKind` lacking a green joint golden fixture **MUST** be blocked; +`package` and `per_unit` each carry their own fixture before first publish, and the +**supersession-continuity** scenario fixture (`inst-tb-window-continuity`) is registered on +the tiered kinds; the §17.2 mapping is the single kind-to-formula source of truth. + +**Implements**: `cpt-cf-bss-pricing-algo-fixture-gate` + +**Touches**: +- DB: `pricing_conformance_fixture_registry` +- Entities: `FixtureGate` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture. + +Unit: + +- [ ] Band-edge cases: adjacent bands share the boundary exactly once (`[0,100) [100,null)`); overlap and gap both fail; a zero-width band (`toQty = fromQty`) fails (`TIER_BAND_EMPTY`); a closed top band fails (`TIER_TOP_CLOSED`); `$0` first band passes; a band whose effective unit price exceeds the previous band's emits the advisory warning (publish succeeds) +- [ ] Band units follow `billingGranularity` quantization (a `per_hour` row's bands count hours; a raw-seconds band definition is rejected/normalized per the documented unit) +- [ ] Kind field matrices: each kind's required/forbidden set; `per_unit` without `quantitySource` fails; `manual` without quantity fails; eval-policy fields on a `flat` non-usage row fail; `graduated` on a `recurring` row fails (`MODEL_KIND_CHARGEKIND_MISMATCH`) +- [ ] Package exclusivity: bands + package fields together fail; `packageSize = 0` fails + +Integration (testcontainers): + +- [ ] A graduated 3-band row publishes and its ordered bands + window appear in the read model exactly as authored +- [ ] A `volume` row publishes as Variant A (no per-band fee field exists to author) +- [ ] Publish with a `package` row while the registry lacks the package fixture is blocked (`FIXTURE_MISSING`); flipping the registry green unblocks +- [ ] Superseding a published row creates a new row + closes the window; UPDATE/DELETE of the published row is rejected by the DB role/trigger +- [ ] The supersession-continuity fixture (`variant = supersession_continuity`) is registered green for the tiered kinds before their first publish; a mid-window supersession scenario keeps the tier counter `Q` continuous + +API: + +- [ ] RFC 9457 mapping for the §5 codes; the publish report enumerates all violations + +## 10. Non-Functional Considerations + +- **Performance**: band validation is O(n log n) per row at publish (authoring path); read-path exposure is a pre-sorted band array in the projected read model (no sort at rating time). Max bands per row is part of the plan/tier size caps — **provisional NFR** to ratify ([`../PRD.md`](../PRD.md) §14). +- **Observability / metrics**: `pricing_fixture_gate_blocks_total{model_kind}`, `pricing_tier_validation_failures_total{rule}`, fixture-registry staleness gauge. +- **Security & AuthZ**: price-row mutation requires the catalog-authoring scope; amount changes flow through the governance slice's materiality check. +- **Risks & open items**: fixture repo/process is cross-team (catalog + Tariffs) and MUST exist **before code** (Q4; PRD §13 gate); Tariffs sign-off that its non-overlap and formula matrix use the identical §17.2/§2.2 keys (ADR `cpt-cf-bss-pricing-adr-canonical-scope-key` confirmation item). diff --git a/gears/bss/pricing/docs/design/04-currency-tax.md b/gears/bss/pricing/docs/design/04-currency-tax.md new file mode 100644 index 000000000..97ced427f --- /dev/null +++ b/gears/bss/pricing/docs/design/04-currency-tax.md @@ -0,0 +1,373 @@ + + + +# DESIGN — Multi-Currency, Regions & Tax Display (Slice 4) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author Multi-Currency Price Rows](#author-multi-currency-price-rows) + - [Preview a Base Price](#preview-a-base-price) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Region and Brand Taxonomy Validation](#region-and-brand-taxonomy-validation) + - [Tax Display Basis and Policy](#tax-display-basis-and-policy) + - [Single-Currency-per-Invoice Binding](#single-currency-per-invoice-binding) +- [4. States (CDSL)](#4-states-cdsl) + - [Tax-Inclusive Sellability State Machine](#tax-inclusive-sellability-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Multi-Currency Rows](#multi-currency-rows) + - [Taxonomy Validation](#taxonomy-validation) + - [Tax Display Basis](#tax-display-basis) + - [Currency Binding](#currency-binding) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **market axes of a price row**: independent per-`(currency, region)` rows +(no FX derivation, ever), the tenant-configured **region and brand taxonomies** with +membership validation before publish, the **`taxInclusive` display basis** + `taxCategory` +reference governed by the fail-closed tenant tax-display policy (with the tax-inclusive +**not-sellable-GA** gate while Tax Engine is post-MVP), and the +**single-currency-per-invoice binding** checks that reject configurations forcing +mixed-currency lines onto one invoice. It registers its rules into the Foundation pipeline; +tax **scheme determination and calculation** are explicitly not here (Tax Engine). + +**Traces to**: `cpt-cf-bss-pricing-fr-multi-currency-rows`, +`cpt-cf-bss-pricing-fr-region-brand-taxonomy`, `cpt-cf-bss-pricing-fr-tax-display-basis`, +`cpt-cf-bss-pricing-fr-invoice-currency-binding`, `cpt-cf-bss-pricing-fr-price-amount-validation` + +### 1.2 Purpose + +Let a tenant sell one plan in many markets — ≥ 20 currencies per plan as a guaranteed floor — +with every market row first-class and independently authored, while making the two classic +failure classes impossible at publish: a price resolved through implicit FX (silently wrong +amount) and an invoice forced to mix currencies (unpostable downstream). Tax display stays a +catalog concern; everything else about tax is delegated. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Authors per-`(currency, region)` rows and `taxInclusive` flags | +| `cpt-cf-bss-pricing-actor-catalog-admin` | Configures the region/brand taxonomies and the tax-display policy | +| `cpt-cf-bss-pricing-actor-tax-engine` | (Post-MVP) consumes `taxInclusive` + `taxCategory`; maps `region` → tax jurisdiction | +| `cpt-cf-bss-pricing-actor-billing` | Depends on the single-currency-per-invoice invariant | +| `cpt-cf-bss-pricing-actor-partner` | Reads the base-price preview (with the preview grant, Slice 5) | +| `cpt-cf-bss-pricing-actor-subscriptions` | Owns currency selection at activation (consumes covered currencies) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.4, §4.1 (environment: precision, taxonomies), §17.4 (currency-coverage / precision / tax-basis rules), §15 (Tax Engine post-MVP) +- **Design**: [01-foundation.md](./01-foundation.md) — money constraint, scope key (§4.1), policy objects +- **Dependencies**: Foundation (Slice 1), plan-definition (Slice 2). The brand taxonomy authored here is consumed by price-overlays (Slice 9: brand-scoped `PriceOverlay`); currency-coverage checks extend to bundles in Slice 8. + +### 1.5 Scope + +**In scope**: per-`(currency, region)` price rows + the ≥ 20-currency floor; region taxonomy +(price-row axis) and brand taxonomy (PriceOverlay scope value) validation; `taxInclusive` / +`taxCategory` persistence + the tenant tax-display policy (default fail-closed); the +tax-inclusive not-sellable-GA gate; the three enumerated currency-binding rejections; the +fail-closed base-price preview read on `(currency, region)`. + +**Out of scope**: FX math and any currency conversion (Tariffs/PLAL; `currencyFallbackPolicy` +is Future); tax scheme/calc and `region` → jurisdiction mapping (Tax Engine, post-MVP); +currency selection at activation (Subscriptions); brand-scoped `PriceOverlay` authoring +(Slice 9); bundle currency coverage detail (Slice 8, reuses this slice's checker). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set (ISO 4217 minor units, `≥ 0`, no implicit FX, UTC, tenant isolation). Slice-4-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| C1 | Currency floor | ≥ 20 currencies per plan guaranteed; the read SLO (p95 < 100ms) holds at that floor | PRD §6.4 | +| C2 | Taxonomies are tenant config | `region` and `brand` value sets are tenant-configured taxonomies; membership is validated at save/publish (unknown value fails before publish) | PRD §4.1 | +| C3 | Tax Engine post-MVP | MVP sells **tax-exclusive**; a `taxInclusive=true` row is authorable but flagged **not-sellable-GA** (per row / market) until Tax Engine GA (ETA ~8 months) | PRD §15 | +| C4 | Tax-display policy default | Fail-closed for **all** tenants: `taxInclusive=true` without region tax readiness, and `taxInclusive=false` in a region with no configured `taxCategory`, both block publish unless the tenant policy explicitly selects warn. The check's input is the **`RegionTaxReadiness` lookup** — `(tenant, region) → { taxCategory, ratePresent }`, fail-closed on unknown; MVP provider = tenant-declared columns on `pricing_region_taxonomy` (§6), post-GA provider = Tax Engine-backed (contract lands in the Tax Engine PRD) | PRD §17.6; D-01 | +| C5 | Region ≠ authz region | Pricing `region` is a commercial territory; the IdP authorization-region claim governs *who may mutate*, enforced in Slice 5 | PRD §1.4 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary; inherits Foundation mechanics. Not restated. + +Design-introduced names (Slice 4): + +| Name | Meaning | +|------|---------| +| `TaxonomyValidator` | Registered rules: `region` membership on price rows; `brand` membership on brand-scoped `PriceOverlay`s (rule shared with Slice 9) | +| `TaxDisplayValidator` | Registered rules: `taxInclusive`/`taxCategory` completeness under the tenant tax-display policy (C4) + the GA gate (C3) | +| `CurrencyBindingChecker` | Registered rules: the three enumerated mixed-currency rejection configs (§3); reused by Slice 8 for bundles | +| `not_sellable_ga` | Read-model flag on a tax-inclusive **price row** (⇒ per `(currency, region)` market) while Tax Engine is pre-GA: authorable, previewable, **not sellable** on that market | +| `RegionTaxReadiness` | The C4 input port: `(tenant, region) → { taxCategory, ratePresent }`, fail-closed on unknown. MVP provider: tenant-declared columns on `pricing_region_taxonomy`; post-GA provider: Tax Engine-backed (sync lookup or event-fed mirror — decided in the Tax Engine PRD) | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s4["Slice 4 — Currency & Tax Display"] + TXV["TaxonomyValidator"] + TDV["TaxDisplayValidator"] + CBC["CurrencyBindingChecker"] + end + CFG[("Tenant taxonomies
region · brand
+ tax-display policy")] + FND["Foundation (Slice 1)
ValidationPipeline · pricing_policy_object"] + TAX["Tax Engine (post-MVP)
scheme · region→jurisdiction"] + CFG --> s4 + TXV --> FND + TDV --> FND + CBC --> FND + FND -. taxInclusive · taxCategory .-> TAX +``` + +**Consumed:** tenant region/brand taxonomies + the tax-display policy +(`pricing_policy_object`, Foundation). **Produced:** validated per-market rows, the +`not_sellable_ga` flag, and the currency-coverage guarantee the sellability gate (Slice 7) +and bundles (Slice 8) build on. + +## 2. Actor Flows (CDSL) + +### Author Multi-Currency Price Rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-multicurrency-author` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Success Scenarios**: +- Independent rows per `(currency, region)` attach to one `planId` (distinct scope keys), `PriceCreated` per row; ≥ 20 currencies supported per plan (C1) +- Each row's amount validates at its own currency's ISO 4217 minor unit (Foundation) + +**Error Scenarios**: +- Unknown `region` → `REGION_UNKNOWN` (422, before publish) +- Precision above the currency's minor unit → `PRECISION_EXCEEDED` (422, Foundation) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans/{planId}/prices per `(currency, region)` (Slice 3 flow; this slice adds the market-axis rules) - `inst-mc-create` +2. [ ] - `p1` - `TaxonomyValidator` checks `region` membership at save **and** publish (C2) - `inst-mc-region` +3. [ ] - `p1` - No FX derivation ever: a missing `(currency, region)` row is simply absent — preview/publish paths fail closed on it, no base-currency fallback (Future `currencyFallbackPolicy` only) - `inst-mc-nofx` +4. [ ] - `p1` - **RETURN** 201 per row - `inst-mc-return` + +### Preview a Base Price + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-flow-price-preview` + +**Actor**: `cpt-cf-bss-pricing-actor-partner`, `cpt-cf-bss-pricing-actor-finance-manager` (requires the catalog-preview read grant, Slice 5) + +**Success Scenarios**: +- Returns the catalog **base list price** for a `(region, currency)`: amount, `taxInclusive` flag, tier summary, `displayTrialDays`, with an explicit disclaimer that Contract/`PriceOverlays` apply at purchase (Tariffs evaluates) + +**Error Scenarios**: +- No row for the requested `(currency, region)` → `PRICE_ROW_ABSENT` (404, fail closed — no FX) +- Principal without the preview grant → denied (403, audited; Slice 5) + +**Steps**: +1. [ ] - `p2` - API: GET /v1/pricing/plans/{planId}/preview?currency=®ion= - `inst-pv-api` +2. [ ] - `p2` - Resolve from the **published read model only** (no draft read); base `priceOverlay` only, overlays disclaimed - `inst-pv-resolve` +3. [ ] - `p2` - **RETURN** 200 (base price + disclaimer) or fail closed - `inst-pv-return` + +## 3. Processes / Business Logic (CDSL) + +### Region and Brand Taxonomy Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-taxonomy` + +**Input**: a price row's `region`; a brand-scoped `PriceOverlay`'s `brand` (Slice 9 registers into the same rule) +**Output**: pass, or a fail-closed violation naming the unknown value + +**Steps**: +1. [ ] - `p1` - `region` MUST be a member of the tenant's configured region taxonomy; an unknown/invalid region fails validation **before** publish - `inst-tx-region` +2. [ ] - `p1` - `brand` is **not** a price-row field (Foundation §4.1): a brand-scoped `PriceOverlay`'s `brand` MUST be a member of the tenant's brand taxonomy, validated at save (rule owned here, exercised by Slice 9) - `inst-tx-brand` +3. [ ] - `p1` - Taxonomy mutation (add/retire a region/brand value) is tenant-admin config, audited; **retiring** a value is rejected while it is referenced by an active published price row (`region`) **or an active brand-scoped `PriceOverlay` scope (`brand`)** — referential integrity over both referencing shapes - `inst-tx-mutation` + +### Tax Display Basis and Policy + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-tax-display` + +**Input**: a row's `taxInclusive` + `taxCategory` + the tenant tax-display policy + the `RegionTaxReadiness` lookup (C4) +**Output**: pass / warn / fail per policy; the `not_sellable_ga` flag where applicable + +**Steps**: +1. [ ] - `p1` - Persist `taxInclusive` (display basis) and the `taxCategory` reference **only** — no scheme determination, no calculation, no `region` → jurisdiction mapping (Tax Engine) - `inst-td-persist` +2. [ ] - `p1` - Policy check (C4): `taxInclusive=true` in a region whose `RegionTaxReadiness` has `ratePresent=false` **and** `taxInclusive=false` in a region with no configured `taxCategory` are both governed by the tenant tax-display policy — default **fail-closed**, explicit warn allowed. Readiness is resolved per `(tenant, region)`; an unknown region fails closed - `inst-td-policy` +2a. [ ] - `p1` - **Readiness provider (D-01):** at MVP `RegionTaxReadiness` reads the tenant-declared `tax_category`/`tax_rate_present` columns on `pricing_region_taxonomy` (CatalogAdmin, `config × write`, audited) — it catches **configuration** mistakes; rate correctness is unverifiable before Tax Engine. Once Tax Engine GAs, the provider becomes Tax Engine-backed and the tenant-declared markers are **reconciled** against its registry: a divergence (declared ready, engine disagrees) flags affected published rows + raises `pricing.tax.readiness_divergent` (Warn); remediation is a re-publish — never a silent retro-change - `inst-td-readiness` +3. [ ] - `p1` - **GA gate (C3):** while Tax Engine is pre-GA a `taxInclusive=true` **row** MAY be authored and previewed but publishes with the read-model flag `not_sellable_ga` — the flag is **per price row, hence per `(currency, region)` market**, not per plan: a plan selling tax-exclusive in US and tax-inclusive in EU is gated **only** on its EU market(s); the sellability gate (Slice 7) evaluates the flag per scope key; MVP sells tax-exclusive - `inst-td-gagate` +4. [ ] - `p1` - When Tax Engine GAs, clearing `not_sellable_ga` is a re-publish (goes through the pipeline + approval), not a silent flag flip - `inst-td-clear` + +### Single-Currency-per-Invoice Binding + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-currency-binding` + +**Input**: a publishing plan + its add-ons / overrides / bundle composition +**Output**: pass, or the enumerated rejection naming the uncovered currency and the offending component + +**Steps**: +1. [ ] - `p1` - **(i)** An add-on / price-override / **required**-add-on row lacking a row in a currency the base plan publishes → reject (the subscription could not resolve all lines in one bound currency) - `inst-cb-addon` +2. [ ] - `p1` - **(ii)** A `sum_of_parts` bundle whose component rows do not cover **every** currency the bundle sells → reject (Slice 8 invokes this rule with bundle context) - `inst-cb-bundle-sum` +3. [ ] - `p1` - **(iii)** An `own_price` bundle without a matching-currency component set → reject - `inst-cb-bundle-own` +4. [ ] - `p1` - Currency **selection** at activation is Subscriptions-owned; this slice guarantees only that every sellable currency is fully covered. `invoiceGroupingKey` is a layout hint and MUST NOT override this invariant (Billing splits currencies regardless) - `inst-cb-boundary` +5. [ ] - `p1` - **Region binding (normative, joint with Subscriptions):** like currency, the pricing `region` binds **once at activation** — Subscriptions resolves it from the payer's commercial profile (never from a client-supplied parameter) and the bound `(currency, region)` pair freezes into `pricingSnapshotRef`; every subsequent resolution (windows, renewals, overlays) uses the frozen pair. A region re-bind is a plan change, not a drift - `inst-cb-region-binding` + +## 4. States (CDSL) + +### Tax-Inclusive Sellability State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-state-tax-sellability` + +**States** (per price row / market): sellable (tax-exclusive, or tax-inclusive post-Tax-Engine-GA), not_sellable_ga (tax-inclusive, pre-GA) +**Initial State**: per the row's `taxInclusive` and the Tax Engine GA status at publish + +**Transitions**: +1. [ ] - `p1` - **FROM** not_sellable_ga **TO** sellable **WHEN** Tax Engine GAs **and** the row's plan is re-published through the pipeline (with approval); never a silent flip; per row/market - `inst-ts-ga` +2. [ ] - `p2` - The flag lives in the read model; the sellability gate (Slice 7) enforces it jointly with the window/version checks - `inst-ts-enforce` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `GET` | `/v1/pricing/plans/{planId}/preview` | Base-price preview per `(currency, region)`; fail closed, overlay disclaimer | — | +| `GET/PUT` | `/v1/pricing/config/taxonomies/{region\|brand}` | Tenant taxonomy read/update (admin, audited) | ETag | +| `GET/PUT` | `/v1/pricing/config/tax-display-policy` | Tenant tax-display policy (fail-closed default) | ETag | + +**Problem responses (RFC 9457):** `REGION_UNKNOWN` (422), `BRAND_UNKNOWN` (422), +`TAXONOMY_VALUE_IN_USE` (409, on retire), `TAX_BASIS_INCOMPLETE` (422, per policy), +`CURRENCY_NOT_COVERED` (422, naming component + currency), `PRICE_ROW_ABSENT` (404 preview, +fail closed). Price-row authoring codes are Slice 3's. + +## 6. Data Model + +Slice-owned tables (tenant-scoped, SecureORM; `pricing_` prefix per Foundation §3.7): + +**`pricing_region_taxonomy`** / **`pricing_brand_taxonomy`** (PK `(tenant_id, value)`): + +| Column | Type | Notes | +|--------|------|-------| +| `tenant_id` | `uuid` | RLS scope | +| `value` | `string` | the region / brand code | +| `display_name` | `string` | operator label | +| `state` | `enum` | `active \| retired`; retire rejected while referenced by an active published price row (`region`) or an active brand-scoped `PriceOverlay` scope (`brand`) | +| `tax_category` | `string` | **region taxonomy only** (D-01): the region's default tax category; a price row's `tax_category_ref` may override | +| `tax_rate_present` | `bool` | **region taxonomy only** (D-01): tenant-declared "a tax rate is configured for this region" — the MVP `RegionTaxReadiness` source; reconciled against Tax Engine post-GA | + +**Tax-display policy** — a `pricing_policy_object` entry (Foundation-owned table): +`mode ∈ {fail_closed (default), warn}`; per-tenant. + +**`pricing_price` (Foundation-owned; Slice-4 columns)** — `currency` (scope key), `region` +(scope key), `tax_inclusive` (bool), `tax_category_ref` (string), and the projected +`not_sellable_ga` flag in `pricing_read_model` (derived at publish, not authored). + +Key constraints: FK-like validation (application-level, at save + publish) from +`pricing_price.region` to `pricing_region_taxonomy(active)`; the ≥ 20-currency floor is a +capacity guarantee (load-tested), not a schema constraint. + +## 7. Events & Alarms + +No new event names (`PriceCreated`/`PriceUpdated` per row; policy/taxonomy changes are +audited mutations). Alarm: `pricing.tax.not_sellable_ga_active` (Info gauge — count of +published tax-inclusive rows/markets awaiting Tax Engine GA; visibility for the GA-gate backlog, +PRD risk table). + +## 8. Definitions of Done + +### Multi-Currency Rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-multicurrency` + +A plan **MUST** support independent price rows per `(currency, region)` on one `planId` +(≥ 20 currencies guaranteed, read SLO held at the floor), each validated at its currency's +ISO 4217 minor unit, with **no** FX derivation anywhere — a missing `(currency, region)` row +fails closed on preview and publish. + +**Implements**: `cpt-cf-bss-pricing-flow-multicurrency-author`, `cpt-cf-bss-pricing-flow-price-preview` + +**Touches**: +- API: `GET /v1/pricing/plans/{planId}/preview` +- DB: `pricing_price` (currency/region axes) +- Entities: `TaxonomyValidator` + +### Taxonomy Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-taxonomy` + +`region` on price rows and `brand` on brand-scoped `PriceOverlay`s **MUST** validate against the +tenant taxonomies before publish (unknown value fails); retiring a referenced taxonomy value +**MUST** be rejected; taxonomy mutation is admin-scoped and audited. + +**Implements**: `cpt-cf-bss-pricing-algo-taxonomy` + +**Touches**: +- API: `GET/PUT /v1/pricing/config/taxonomies/*` +- DB: `pricing_region_taxonomy`, `pricing_brand_taxonomy` +- Entities: `TaxonomyValidator` + +### Tax Display Basis + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-tax-display` + +The catalog **MUST** persist `taxInclusive` + `taxCategory` only (no scheme/calc), govern the +two incomplete-basis cases by the tenant tax-display policy (default fail-closed), and flag +tax-inclusive rows `not_sellable_ga` (per market) until Tax Engine GA — cleared only by re-publish. + +**Implements**: `cpt-cf-bss-pricing-algo-tax-display`, `cpt-cf-bss-pricing-state-tax-sellability` + +**Touches**: +- API: `GET/PUT /v1/pricing/config/tax-display-policy` +- DB: `pricing_price` (tax columns), `pricing_policy_object`, `pricing_read_model` (`not_sellable_ga`) +- Entities: `TaxDisplayValidator` + +### Currency Binding + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-currency-binding` + +Publish/preview **MUST** reject the three enumerated mixed-currency configurations +(add-on/override/required-add-on gap; `sum_of_parts` coverage gap; `own_price` mismatch), +naming the component and currency; `invoiceGroupingKey` never overrides the invariant. + +**Implements**: `cpt-cf-bss-pricing-algo-currency-binding` + +**Touches**: +- DB: `pricing_price`, `pricing_plan_addon_rule` +- Entities: `CurrencyBindingChecker` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture. + +Unit: + +- [ ] Minor-unit precision matrix (JPY 0 / USD 2 / BHD 3; over-precision rejected); unknown region/brand rejection; the three currency-binding cases each rejected with the component named; tax-basis policy matrix (fail-closed vs warn × the two incomplete cases: `taxInclusive=true` with `tax_rate_present=false`, `taxInclusive=false` with no `tax_category`) + +Integration (testcontainers): + +- [ ] A plan with 20+ currency rows publishes and the preview reads each `(currency, region)` within the read SLO +- [ ] Preview of an absent `(currency, region)` fails closed (no base-currency fallback) +- [ ] A required add-on missing one of the base plan's currencies blocks publish (`CURRENCY_NOT_COVERED`) +- [ ] A mixed plan (EU `taxInclusive=true` with EU `tax_rate_present=true`, US exclusive) publishes with `not_sellable_ga` on the EU rows only — the US market stays sellable; the flag clears only via re-publish +- [ ] The same plan with EU `tax_rate_present=false` blocks publish under the default fail-closed policy (C4 precedes the C3 flag) +- [ ] Retiring a region referenced by an active published row is rejected (409) + +API: + +- [ ] RFC 9457 mapping for the §5 codes; preview carries the overlay disclaimer + +## 10. Non-Functional Considerations + +- **Performance**: the read/preview SLO (p95 < 100ms) holds at the 20-currency floor — preview is a single read-model lookup keyed by `(plan, currency, region)`; taxonomy checks are indexed lookups on the authoring path only. +- **Observability / metrics**: `pricing_preview_failclosed_total{reason}`, `pricing_currency_binding_blocks_total{case}`, `pricing_tax_not_sellable_ga` gauge. +- **Security & AuthZ**: taxonomy/policy mutation is CatalogAdmin-scoped and audited; preview requires the explicit preview grant (Slice 5); pricing `region` is decoupled from the authz-region claim (enforcement in Slice 5). +- **Risks & open items**: Tax Engine slip extends the `not_sellable_ga` window (PRD risk #1 — tracked on the program board); the MVP `RegionTaxReadiness` markers are self-declared — their post-GA reconciliation (divergence flag + `pricing.tax.readiness_divergent`) is part of the future Tax Engine contract (D-01); `currencyFallbackPolicy` deliberately deferred (fail-closed is the launch behavior); bundle currency coverage is exercised end-to-end only when Slice 8 lands. diff --git a/gears/bss/pricing/docs/design/05-governance.md b/gears/bss/pricing/docs/design/05-governance.md new file mode 100644 index 000000000..c0a88e6f7 --- /dev/null +++ b/gears/bss/pricing/docs/design/05-governance.md @@ -0,0 +1,513 @@ + + + +# DESIGN — Approval, Governance & Access Control (Slice 5) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Approve or Reject a Material Change](#approve-or-reject-a-material-change) + - [Historical Import (Backdating)](#historical-import-backdating) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Materiality Evaluation](#materiality-evaluation) + - [Two-Person Rule Enforcement](#two-person-rule-enforcement) + - [RBAC and Scope Enforcement](#rbac-and-scope-enforcement) + - [AuthZ Resource and Action Catalog (normative)](#authz-resource-and-action-catalog-normative) + - [Audit Trail and Retention](#audit-trail-and-retention) +- [4. States (CDSL)](#4-states-cdsl) + - [Approval State Machine](#approval-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Two-Person Rule](#two-person-rule) + - [Threshold Policy](#threshold-policy) + - [RBAC & Isolation](#rbac--isolation) + - [Backdating Governance](#backdating-governance) + - [Audit Completeness](#audit-completeness) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **who-may-do-what layer** that gates every publish: **materiality +evaluation** against the per-currency tenant threshold policy (fail-safe: no threshold ⇒ +everything is material; first publish ⇒ always material), the **two-person rule** (submitter ++ ≥ 1 independent approver, self-approval rejected and audited), **RBAC deny-by-default** +over both mutate and read/preview surfaces (with the explicit catalog-preview grant and the +restricted historical-import grant), **tenant/brand/region isolation** with the pricing +`region` decoupled from the IdP authz-region claim, and the **append-only, tamper-evident +audit trail** with ≥ 7-year jurisdiction-configurable retention. It plugs into the +Foundation's publish path as the approval step ([`01-foundation.md`](./01-foundation.md) +§4.2 step 3). + +**Traces to**: `cpt-cf-bss-pricing-fr-approval-two-person`, +`cpt-cf-bss-pricing-fr-approval-threshold-policy`, `cpt-cf-bss-pricing-fr-rbac-deny-by-default`, +`cpt-cf-bss-pricing-fr-tenant-brand-isolation`, `cpt-cf-bss-pricing-fr-historical-import-governance`, +`cpt-cf-bss-pricing-fr-audit-completeness` + +### 1.2 Purpose + +Make an unauthorized or self-approved price change — the financial-fraud vector this domain +carries — structurally impossible: no principal can both submit and approve a material +change, no change slips under an unset threshold, no preview leaks pricing to an unlisted +role, backdating exists only as a restricted, justified, audited, downstream-side-effect-free +path, and every mutation leaves an immutable trail an auditor can rely on for 7+ years. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Submits publishes | +| `cpt-cf-bss-pricing-actor-finance-reviewer` | The independent approver; rejects with reason; configures the approval-threshold policy (`approval_policy × write` — deliberately not CatalogAdmin's, see the role-matrix note) | +| `cpt-cf-bss-pricing-actor-catalog-admin` | Configures roles/grants and the config plane (taxonomies, tax-display); runs governed historical imports | +| `cpt-cf-bss-pricing-actor-auditor` | Reads immutable history + approval trails; exports | +| `cpt-cf-bss-pricing-actor-partner` | Holds (only) the catalog-preview read grant | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.7 (approval subset), §6.12, §17.6 (tenant policy objects), §7.1 (audit/retention NFRs), §15 (retention-maximum open item) +- **Design**: [01-foundation.md](./01-foundation.md) — publish contract step 3 (§4.2), `pricing_policy_object`, `pricing_audit_log` +- **Dependencies**: Foundation (Slice 1). The approval step gates the publish flows of every capability slice; the backdating grant is exercised by lifecycle snapshot synthesis (Slice 11). + +### 1.5 Scope + +**In scope**: materiality evaluation (per-currency absolute/percent deltas, any-row-trips +rule, first-publish rule); the approval workflow (submit → approve/reject with reason → +publish); self-approval rejection + audit; RBAC deny-by-default on mutate **and** +read/preview (preview grant; backdating grant); tenant/brand/region scope enforcement (authz +region ≠ pricing region); audit-record completeness, tamper-evidence (in-DB hash chain, +D-14) and retention. + +**Out of scope**: what *makes* a change (versioning/supersession — Foundation §4.3); IdP/role +administration itself (platform IdP; this slice consumes claims); the backdating *use case* +of legacy snapshot synthesis (Slice 11 — it invokes the grant + audit defined here); +customer-group membership changes' materiality semantics (Slice 9 registers its material +triggers — immediate re-resolutions and bulk group moves — into this slice's evaluator; +renewal-aligned single-membership changes are audit-only). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-5-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| G1 | Fail-safe materiality | No configured threshold ⇒ **all** changes material; first publish (no baseline) ⇒ **always** material; auto-publish only below an explicitly configured threshold and not a first publish | PRD §1.4 | +| G2 | Two distinct principals | Submitter ≠ approver as **principals** (not roles): one human with both roles still cannot self-approve | PRD §6.7 | +| G3 | Multi-currency materiality | Each affected row's delta compares in its **own** currency; the rule trips if **any** row exceeds its threshold | PRD §6.7 | +| G4 | Tamper-evidence mechanism | **Hash-chained audit rows in the same database** (D-14; ledger precedent): the audit row commits inside the mutation's ACID transaction — no lost records on crash, and an unavailable audit store cannot exist separately from an unavailable database (fail-closed by construction). A periodic verification job walks the chain (`pricing_audit_chain_verified`); the chain head MAY be **asynchronously anchored** to external WORM/object-lock storage as hardening — never on the mutation path | PRD §6.12; D-14 | +| G5 | Retention | ≥ 7 years, tenant/jurisdiction-configurable as the **maximum applicable minimum**; jurisdictions imposing a storage-limitation **maximum** are an open Legal item | PRD §15 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary; inherits Foundation mechanics. Not restated. + +Design-introduced names (Slice 5): + +| Name | Meaning | +|------|---------| +| `MaterialityEvaluator` | Computes the per-currency delta vs baseline and applies G1/G3; called by the Foundation publish path | +| `ApprovalWorkflow` | The submitted → approved/rejected state machine with the two-person invariant (G2) | +| `ScopeGuard` | Request-time RBAC + tenant/brand/region scope enforcement (deny-by-default; authz region ≠ pricing region) | +| `BackdateGrant` | The distinct restricted grant + mandatory-reason + audit contract for historical import | +| `AuditTrail` | Writer over `pricing_audit_log` guaranteeing actor / before-after / approval completeness + tamper evidence (G4) | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + IDP["IdP / gateway
roles · tenant · brand/region claims"] + subgraph s5["Slice 5 — Governance"] + SG["ScopeGuard"] + ME["MaterialityEvaluator"] + AW["ApprovalWorkflow"] + BG["BackdateGrant"] + AT["AuditTrail"] + end + FND["Foundation (Slice 1)
publish path step 3 · pricing_policy_object · pricing_audit_log"] + IDP --> SG + SG --> FND + ME --> AW + AW --> FND + BG --> AT + AT --> FND +``` + +**Consumed:** IdP claims (roles, tenant, brand/region authz scope); the approval-threshold +policy (`pricing_policy_object`). **Produced:** the approve/reject gate on every publish; the +audit trail every other slice's mutations flow through; the preview/backdating grants. + +## 2. Actor Flows (CDSL) + +### Approve or Reject a Material Change + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-approval` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-reviewer` + +**Success Scenarios**: +- A submitted material change is approved by an independent principal → the Foundation publish proceeds (events, version request) +- A rejection (with mandatory reason) returns the Plan to `draft` and notifies the submitter + +**Error Scenarios**: +- Approver = submitter → `SELF_APPROVAL_FORBIDDEN` (403, audit-logged) +- Approver lacks FinanceReviewer → denied (403, audit-logged) + +**Steps**: +1. [ ] - `p1` - Publish submit (any slice) runs `MaterialityEvaluator`; a non-material change (explicit threshold, below it, not first publish) auto-publishes with no approver - `inst-ap-materiality` +2. [ ] - `p1` - Material → an approval record opens (`submitted`) **pinning the exact content**: `subject_ref` + a content hash of the submitted revision; submitter identity + timestamp logged - `inst-ap-open` +2a. [ ] - `p1` - **Post-submit mutation voids the approval (TOCTOU guard):** any mutation of the subject while `submitted` invalidates the pending approval (subject returns to `draft`, the record closes `voided`, a fresh submit opens a new record); approve re-verifies the pinned content hash and rejects on mismatch (`APPROVAL_CONTENT_MISMATCH`, 409) — a reviewer can only ever approve exactly what they saw; a decision on a `voided`/already-decided record is rejected (`APPROVAL_NOT_PENDING`, 409) - `inst-ap-pin` +3. [ ] - `p1` - API: POST /v1/pricing/approvals/{id}/approve | reject (reason mandatory on reject) - `inst-ap-decide` +4. [ ] - `p1` - `ApprovalWorkflow` enforces G2 (two distinct principals; self-approval rejected + audited) - `inst-ap-twoperson` +4a. [ ] - `p1` - **Approver scope:** the approver's authz claims MUST cover every region/brand touched by the pinned change set (an EU-scoped reviewer cannot approve a US repricing); an out-of-scope approve is denied + audited like any scope violation - `inst-ap-scope` +5. [ ] - `p1` - **RETURN** approve → Foundation continues (§4.2 steps 4–5); reject → Plan back to `draft`, submitter notified - `inst-ap-return` + +### Historical Import (Backdating) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-backdating` + +**Actor**: `cpt-cf-bss-pricing-actor-catalog-admin` (holding the distinct `BackdateGrant`) + +**Success Scenarios**: +- A historical import with past `availableFrom`/effective dates lands as **reference data** with a mandatory reason, fully audited; used e.g. for legacy snapshot synthesis (Slice 11, `migrated-origin`) + +**Error Scenarios**: +- Principal without the backdating grant → denied (403, audited); the grant is never part of a default role +- Import that would generate or re-open a downstream billable charge window → rejected (`BACKDATE_SIDE_EFFECT`) +- A row failing the row-shape pipeline subset (unknown region, bad precision, duplicate scope key, malformed modelKind/eval-policy) → rejected with the enumerated report (D-13) +- Import applied without an independent approval → impossible: every import is an always-material approval unit (D-13) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/historical-imports (grant-gated; `reason` REQUIRED) - `inst-bd-api` +2. [ ] - `p1` - Validate zero downstream billable effect: imported rows are reference/history only — no window scheduling, no event that warms a rateable state for past periods - `inst-bd-noeffect` +2a. [ ] - `p1` - **Row-shape pipeline (D-13):** every imported row runs the fail-closed pipeline's row-shape subset — taxonomy membership, ISO 4217 minor-unit precision, scope-key uniqueness (incl. vs other imported rows), modelKind/evaluation-policy shape; window-coverage, sellability and `CatalogVersion`-addressability checks do **not** apply (reference rows are resolved only via snapshot-synthesis provenance, never via window resolution). An import can never create a row regular authoring would reject - `inst-bd-pipeline` +2b. [ ] - `p1` - **Two-person (D-13):** every historical import is an **always-material approval unit** (registered trigger): the `BackdateGrant` holder submits, an independent FinanceReviewer approves — the rows land only on approval (standard pin/void semantics). Rationale: backdated rows shape `migrated-origin` snapshots that rating consumes going forward; "zero billable effect" holds for past periods, not future rating - `inst-bd-twoperson` +3. [ ] - `p1` - `AuditTrail` records actor, reason, full row set, provenance (+ the approval trail) - `inst-bd-audit` +4. [ ] - `p1` - **RETURN** 202 (submitted for approval) → 201-equivalent completion on approval (reference rows) — the **only** sanctioned backdated path - `inst-bd-return` + +## 3. Processes / Business Logic (CDSL) + +### Materiality Evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-materiality` + +**Input**: the submitted change set + the tenant approval-threshold policy + the prior published baseline (if any) +**Output**: `material` | `auto_publishable` + +**Steps**: +1. [ ] - `p1` - No explicitly configured threshold → **material** (G1, fail-safe) - `inst-mat-failsafe` +2. [ ] - `p1` - First publish (no prior baseline — no delta computable) → **material** (G1) - `inst-mat-first` +3. [ ] - `p1` - Otherwise compute per-row deltas **in each row's own currency** (absolute or percent per policy); **any** row over its threshold trips the whole change (G3); a row whose currency has **no threshold entry** in the configured policy is **material** (the G1 fail-safe applies per currency, not per policy object). Materiality is evaluated **once at submit**; a later threshold-policy change neither re-evaluates nor voids a pending approval - `inst-mat-percurrency` +3a. [ ] - `p1` - **A row without its own baseline is material:** adding a new row to a published plan (a new currency/region/phase/chargeKind key) has no prior row to delta against — per the G1 fail-safe it is **always material**, regardless of thresholds - `inst-mat-newrow` +4. [ ] - `p1` - Registered material-change sources beyond price deltas — **always-material triggers**: `grandfatherUntil` tightening (Foundation §4.3), grandfathering cutovers (Slice 7), **plan retirement while a cutover unit is pending/approved-not-yet-effective** (Slice 11 — the retirement unwinds the approved unit, D-05), **immediate** membership re-resolutions and bulk group discounts/moves (Slice 9 — renewal-aligned single-membership changes are audit-only, not material), **historical imports** (this slice, D-13 — every import, since backdated rows shape `migrated-origin` snapshots), and **approval-threshold-policy mutations themselves** (this slice, D-10 — direction-agnostic: any policy diff needs an independent second FinanceReviewer; the two-person rule's foundation must not be single-person-editable. Bootstrap is fail-safe: a single-reviewer tenant simply leaves the policy unset ⇒ everything material). Grant-price changes (Slice 10) are **not** always-material: they are evaluated as ordinary price deltas under the per-currency threshold policy - `inst-mat-registered` + +### Two-Person Rule Enforcement + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-two-person` + +**Input**: an approval decision request +**Output**: accepted decision, or a rejected + audited violation + +**Steps**: +1. [ ] - `p1` - Approver principal MUST differ from the submitter principal (G2 — identity comparison, not role) - `inst-tp-distinct` +2. [ ] - `p1` - A self-approval attempt is rejected **and** written to `pricing_audit_log` (attempted-violation record) - `inst-tp-selfaudit` +3. [ ] - `p1` - Submitter and approver identities + timestamps land on the approval record and in the audit trail; a rejection carries its mandatory reason - `inst-tp-record` + +### RBAC and Scope Enforcement + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-rbac` + +**Input**: every request (mutate, read, preview) + IdP claims +**Output**: allow, or deny (audited) + +**Steps**: +1. [ ] - `p1` - **PEP/PDP model (platform-standard, ledger precedent):** every ctx-bearing service path calls the shared `access_scope` gate (`authz_resolver_sdk::PolicyEnforcer`) with a `(resource_type, action)` pair from the §AuthZ catalog **before** touching the repository; the PDP-compiled `AccessScope` is the SQL filter SecureORM binds to `tenant_id` (reads) and the write-target membership assertion (writes). Stub type-schemas for every label register at gear init so RBAC role-definitions can target them - `inst-rb-pep` +2. [ ] - `p1` - **Deny-by-default, both directions**: mutate APIs permit exactly the roles the role matrix grants the relevant `(resource, action)` — e.g. `plan × write`: ProductManager/FinanceManager/CatalogAdmin — never a hardcoded role list; read/preview APIs deny an unlisted-role principal unless it holds the explicit **catalog-preview read grant** (`plan × preview`, region/brand-scoped by claims) - `inst-rb-deny` +3. [ ] - `p1` - Denied attempts are audit-logged (actor, surface, claim set) - `inst-rb-audit` +4. [ ] - `p1` - **Tenant isolation** (SecureORM, Foundation) + brand/region **authz scoping** at the gateway; mutating a price row whose pricing `region` the caller's authz scope does not grant is denied + audited — pricing `region` is a commercial axis, never conflated with the authz-region claim - `inst-rb-region` +5. [ ] - `p1` - The **backdating grant** (`historical_import × write`) is a distinct restricted resource, never included in a default role (`BackdateGrant`) - `inst-rb-backdate` + +### AuthZ Resource and Action Catalog (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-authz-catalog` + +**Input**: every API surface of every slice (2–12) +**Output**: the single `(resource_type, action)` catalog the PEP enforces and RBAC roles target + +**Resource-type labels** — GTS ids `gts.cf.bss.pricing..v1~`, all **OUTSIDE** +`gts.cf.resources.*` (pricing data is commercially sensitive: the built-in +Reader/Contributor/Owner roles do NOT auto-cover it; access requires explicit catalog +roles). Each action sits on its **real object** (a noun), never an authz tier: + +| Label | Object | Actions | +|-------|--------|---------| +| `gts.cf.bss.pricing.plan.v1~` | Plans + price rows + row-attached primitives (the authoring data plane) | `write` (draft create/update/clone/delete-draft, cutover, `grandfatherUntil` tighten), `publish` (submit for publish), `retire`, `migrate` (schedule/cancel a migration), `read` (authoring read incl. drafts), `preview` (the partner-facing base-price preview grant) | +| `gts.cf.bss.pricing.bundle.v1~` | Bundle composition + rev-share | `write`, `read` | +| `gts.cf.bss.pricing.price_overlay.v1~` | `PriceOverlays` (all scopes) | `write`, `read` | +| `gts.cf.bss.pricing.customer_group.v1~` | Group taxonomy + per-payer membership (payer-level commercial data — its OWN resource, more sensitive than plan authoring) | `write`, `read` | +| `gts.cf.bss.pricing.approval.v1~` | Approval decisions | `approve` (approve/reject; `preparer ≠ approver` enforced server-side), `read` | +| `gts.cf.bss.pricing.approval_policy.v1~` | The tenant approval-threshold policy — deliberately a **SEPARATE** resource from `config` (segregation of duties: a config admin must not weaken its own approval thresholds; ledger `dual_control_policy` precedent), and its mutation is **itself two-person** (always-material approval unit — D-10) | `write`, `read` | +| `gts.cf.bss.pricing.config.v1~` | Tax-display policy + region/brand taxonomies (the tenant config plane) | `write`, `read` | +| `gts.cf.bss.pricing.historical_import.v1~` | Governed backdated reference import — its OWN resource so the restricted `BackdateGrant` is targetable without any other authority | `write` | +| `gts.cf.bss.pricing.audit.v1~` | Audit/history read + export — its OWN resource so a forensic/audit role carries no read of live pricing and no write authority | `read`, `export` | + +**Endpoint → `(resource, action)` mapping** — every REST surface declared by Slices 2–12: + +| Surface (slice) | Resource × Action | +|-----------------|-------------------| +| `POST/PATCH /v1/pricing/plans*`, `POST /v1/pricing/plans/{id}/prices*`, `DELETE …/prices/{id}` (S2/S3) | `plan × write` | +| `POST /v1/pricing/plans/{id}/publish` (S2) | `plan × publish` | +| `GET /v1/pricing/plans*`, `GET …/prices`, `GET …/coverage`, `GET …/sellability`, `GET /v1/pricing/migrations/{id}`, `GET /v1/pricing/bulk-imports/{id}`, `GET /v1/pricing/repricing-runs/{id}` (S2/S3/S7/S11/S12) | `plan × read` | +| `GET /v1/pricing/plans/{id}/preview` (S4) | `plan × preview` | +| `POST /v1/pricing/plans/{id}/cutovers`, `PATCH /v1/pricing/prices/{id}/grandfather-until` (S7) | `plan × write` (+ material approval) | +| `POST /v1/pricing/prices/{id}/windows`, `PATCH/DELETE /v1/pricing/price-windows/{id}` (S7 — owned window machinery, D-03) | `plan × write` (a window is an attribute of the row's sellable life) | +| Plan retirement / `POST` migration schedule / cancel (S11) | `plan × retire` / `plan × migrate` | +| `POST/PATCH /v1/pricing/bundles*` (S8 authoring) | `bundle × write` | +| `POST /v1/pricing/bundles/{id}/publish` (S8) | `plan × publish` **only** (D-11) — the composition was authored under `bundle × write` and is protected by the approval content pin at publish time; component checks inside publish are validations, not caller authz | +| `POST/PATCH /v1/pricing/price-overlays*` (S9) | `price_overlay × write`; `GET` → `price_overlay × read` | +| `/v1/pricing/customer-groups/*` (S9: taxonomy + membership) | `customer_group × write` / `read` | +| `GET/POST /v1/pricing/approvals*` (S5) | `approval × read` / `approve` | +| `GET/PUT /v1/pricing/config/approval-threshold-policy` (S5) | `approval_policy × read` / `write` | +| `GET/PUT /v1/pricing/config/taxonomies/{region,brand}`, `GET/PUT /v1/pricing/config/tax-display-policy` (S4) | `config × read` / `write` — the customer-group taxonomy is **not** here: it lives at `/v1/pricing/customer-groups/taxonomy` under `customer_group` (more sensitive) | +| `POST /v1/pricing/historical-imports` (S5/S11) | `historical_import × write` | +| `GET /v1/pricing/audit` (S5) | `audit × read` / `export` — **Auditor-only** (actor trails, before/after, approval decisions; D-12) | +| `GET /v1/pricing/history`, `POST /v1/pricing/history/export` (S12) | `plan × read` — price history is plan/price data (chronological view over append-only rows), Finance-readable by construction (D-12) | +| Bulk import / mass repricing / clone (S12) | the **same** `plan × write` / `publish` — bulk is authoring at scale, no new authority | +| Published read model (Tariffs/Rating/Subscriptions/Billing) | service-to-service identities with `plan × read` scoped by the platform service trust; never the human preview grant | + +**Role → permission matrix** (targeted via the registered label type-schemas): + +| Role / grant | Permissions | +|--------------|-------------| +| **ProductManager** | `plan × write/read`, `bundle × write/read` | +| **FinanceManager** | `plan × write/publish/read`, `bundle × read`, `price_overlay × read` | +| **CatalogAdmin** | `plan × write/publish/retire/migrate/read`, `bundle × write/read`, `price_overlay × write/read`, `customer_group × write/read`, `config × write/read`, `approval × read` | +| **FinanceReviewer** | `approval × approve/read`, `approval_policy × write/read`, `plan × read` | +| **Auditor** | `audit × read/export`, `plan × read` | +| **Preview grant** (partner) | `plan × preview` only (region/brand-scoped) | +| **BackdateGrant** | `historical_import × write` only — never bundled into the roles above | + +Notes: **no role carries both `plan × publish` and `approval × approve`** in the default +matrix at the *principal* level — the two-person rule additionally enforces +`submitter ≠ approver` server-side even when a custom role grants both. CatalogAdmin +deliberately lacks `approval_policy × write` (it cannot weaken thresholds it operates +under) and `approval × approve` (it publishes, it does not approve itself). + +### Audit Trail and Retention + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-audit` + +**Input**: every plan/price mutation, approval decision, denied attempt, backdated import +**Output**: an immutable, tamper-evident audit record + +**Steps**: +1. [ ] - `p1` - Record completeness: actor, timestamp, **before/after version refs**, approval trail (submitter/approver/decision/reason), correlation id - `inst-au-complete` +1a. [ ] - `p1` - **PII minimization:** the audit trail stores **pseudonymous principal ids**, never display names/emails — the 7-year retention then holds no directly-identifying operator PII and GDPR erasure of a departed operator stays an IdP concern, not an audit rewrite - `inst-au-pii` +2. [ ] - `p1` - Tamper evidence per G4 (D-14): append-only role + triggers (as the Foundation tables) **plus** in-DB hash-chained rows committed in the mutation transaction; a periodic job verifies the chain (`pricing_audit_chain_verified`), and the chain head MAY be async-anchored to external WORM/object-lock storage — prior versions cannot be mutated or deleted within retention - `inst-au-tamper` +3. [ ] - `p1` - Retention ≥ 7 years, tenant/jurisdiction-configurable as the **maximum applicable minimum** (G5); the storage-limitation-maximum question is an open Legal item — the retention engine takes a per-jurisdiction config, not a hardcoded value - `inst-au-retention` +4. [ ] - `p2` - Auditor read surface: the **audit trail** (actor + before/after + approval decisions) under **Auditor-only** filters (`audit × read`); Finance's chronological **price history** is the separate Slice 12 surface under `plan × read` (D-12); export p95 ≤ 5s / 100 records applies to both - `inst-au-read` + +## 4. States (CDSL) + +### Approval State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-approval` + +**States**: submitted, approved, rejected, voided +**Initial State**: submitted (opened by a material publish; submitter recorded) + +**Transitions**: +1. [ ] - `p1` - **FROM** submitted **TO** approved **WHEN** an independent FinanceReviewer approves (G2 holds) → the Foundation publish continues - `inst-as-approve` +2. [ ] - `p1` - **FROM** submitted **TO** rejected **WHEN** the reviewer rejects with a mandatory reason → the Plan returns to `draft`, the submitter is notified. For **non-plan subjects**, reject returns the change set to its slice-defined pre-submit state (cutover unit discarded, membership change not applied, batch not committed) — the owning slice defines the surface; this slice defines the record - `inst-as-reject` +3. [ ] - `p1` - **FROM** submitted **TO** voided **WHEN** the pinned subject mutates post-submit (TOCTOU guard, `inst-ap-pin`); a fresh submit opens a new record - `inst-as-void` +4. [ ] - `p1` - Approval records are immutable once decided (append-only; a re-submit opens a **new** record) - `inst-as-immutable` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `GET` | `/v1/pricing/approvals` | List pending/decided approvals (reviewer scope) | — | +| `POST` | `/v1/pricing/approvals/{id}/approve` | Approve (independent principal only) | per decision | +| `POST` | `/v1/pricing/approvals/{id}/reject` | Reject with mandatory reason | per decision | +| `GET/PUT` | `/v1/pricing/config/approval-threshold-policy` | Tenant threshold policy (per-currency; unset ⇒ two-person always). The PUT opens an **always-material approval unit** — the diff applies only after an independent FinanceReviewer approves (D-10) | ETag + approval unit | +| `POST` | `/v1/pricing/historical-imports` | Governed backdated reference import (`BackdateGrant` + reason) | client idempotency key | +| `GET` | `/v1/pricing/audit` | Auditor read (filters; export) | — | + +**Problem responses (RFC 9457):** `SELF_APPROVAL_FORBIDDEN` (403), `APPROVAL_ROLE_REQUIRED` +(403), `APPROVAL_CONTENT_MISMATCH` (409 — the pinned content hash no longer matches at +decision time, `inst-ap-pin`), `APPROVAL_NOT_PENDING` (409 — decision on a voided/decided +record), `REASON_REQUIRED` (422), `BACKDATE_GRANT_REQUIRED` (403), `BACKDATE_SIDE_EFFECT` +(422), `REGION_SCOPE_DENIED` (403), `THRESHOLD_INVALID` (422). + +## 6. Data Model + +Slice-owned tables (tenant-scoped, SecureORM; `pricing_` prefix per Foundation §3.7): + +**`pricing_approval`** (PK `approval_id`; immutable once decided): + +| Column | Type | Notes | +|--------|------|-------| +| `approval_id` | `uuid` | PK | +| `tenant_id` | `uuid` | RLS scope | +| `subject_ref` | `uuid` | the plan revision / change set under approval | +| `content_hash` | `bytes` | hash of the pinned submitted content; approve re-verifies it (TOCTOU guard). For a **batch** subject (bulk import / mass repricing) the pin is a **per-row hash set** and this column holds its digest — enabling Slice 12's subset-commit + unchanged-row approval reuse | +| `state` | `enum` | `submitted \| approved \| rejected \| voided` | +| `submitter_principal` | `string` | identity, not role | +| `approver_principal` | `string` | NULL until decided; `CHECK (approver_principal <> submitter_principal)` | +| `reason` | `text` | mandatory on reject | +| `materiality` | `jsonb` | evaluator output: per-currency deltas, tripped rows, trigger source | +| `submitted_at` / `decided_at` | `timestamptz` | UTC | + +**Approval-threshold policy** — a `pricing_policy_object` entry (Foundation-owned): +per-currency `{absolute_minor | percent}` thresholds; **unset ⇒ two-person rule always** (G1). + +**`pricing_audit_log` (Foundation-owned; this slice is the writer contract)** — actor, +timestamp, before/after version refs, approval trail, correlation id, denied-attempt records, +backdate provenance; append-only + tamper evidence (G4); per-jurisdiction retention config (G5). + +**Grants** — the catalog-preview read grant and the `BackdateGrant` are IdP/gateway-managed +claims; this slice defines their semantics and enforcement points, not their administration. + +## 7. Events & Alarms + +No new frozen event names (approval outcomes gate `PlanPublished` emission; they do not add +events). Alarms: `pricing.governance.self_approval_attempt` (Warn — audited violation +attempts), `pricing.governance.denied_mutation_burst` (Warn — potential probing), +`pricing.audit.chain_gap` (Critical — tamper-evidence verification failure, G4). + +## 8. Definitions of Done + +### Two-Person Rule + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-two-person` + +A material change **MUST** require one independent approver (two distinct principals) before +`PlanPublished`; self-approval **MUST** be rejected and audit-logged; identities + timestamps +**MUST** be recorded; rejection returns the plan to `draft` with reason + notification. The +submission **MUST** pin the content hash; post-submit mutation voids the approval; approve +verifies the pin and the approver's region/brand scope over the pinned change. + +**Implements**: `cpt-cf-bss-pricing-flow-approval`, `cpt-cf-bss-pricing-algo-two-person`, `cpt-cf-bss-pricing-state-approval` + +**Touches**: +- API: `POST /v1/pricing/approvals/{id}/approve|reject` +- DB: `pricing_approval` +- Entities: `ApprovalWorkflow` + +### Threshold Policy + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-threshold` + +Materiality **MUST** evaluate per-currency (absolute/percent, any-row-trips), with the +fail-safe defaults: unset threshold ⇒ all material; first publish ⇒ always material; +auto-publish only below an explicit threshold and never on a first publish; a row whose +currency lacks a threshold entry is material. Registered always-material triggers +(grandfather tightening, cutovers, retirement-with-live-cutover, immediate membership +re-resolutions / bulk group moves, **threshold-policy mutations themselves — D-10**) **MUST** +route through the same evaluator; grant-price changes evaluate as ordinary price deltas. + +**Implements**: `cpt-cf-bss-pricing-algo-materiality` + +**Touches**: +- API: `GET/PUT /v1/pricing/config/approval-threshold-policy` +- DB: `pricing_policy_object`, `pricing_approval` (materiality) +- Entities: `MaterialityEvaluator` + +### RBAC & Isolation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-rbac` + +Mutate **and** read/preview surfaces **MUST** be deny-by-default (preview needs the explicit +grant, region/brand-scoped); denied attempts audit-logged; tenant isolation enforced; a +mutation scoped to a pricing `region` outside the caller's authz scope **MUST** be denied + +audited (authz region never conflated with pricing region). Every surface **MUST** enforce +through the shared PEP gate with its catalogued `(resource, action)` pair; the label +type-schemas **MUST** register at gear init; the label set and the endpoint mapping are +normative per the AuthZ catalog. + +**Implements**: `cpt-cf-bss-pricing-algo-rbac`, `cpt-cf-bss-pricing-algo-authz-catalog` + +**Touches**: +- DB: `pricing_audit_log` (denial records) +- Entities: `ScopeGuard` (PEP gate + `resource_types`/`actions` catalog + label type-schema registration) + +### Backdating Governance + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-backdating` + +Historical import **MUST** require the distinct restricted grant + a mandatory reason, run +the row-shape pipeline subset (an import can never create a row regular authoring would +reject), route through an **always-material approval** (independent second person — D-13), be +fully audited (incl. the approval trail), and produce **zero** downstream billable effect +(reference/history rows only) — the only sanctioned backdated path. + +**Implements**: `cpt-cf-bss-pricing-flow-backdating` + +**Touches**: +- API: `POST /v1/pricing/historical-imports` +- DB: `pricing_audit_log` +- Entities: `BackdateGrant`, `AuditTrail` + +### Audit Completeness + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-audit` + +Every mutation **MUST** record actor/timestamp/before-after/approval trail — the actor as a +**pseudonymous principal id** (no display names/emails); history retained +≥ 7 years (jurisdiction-configurable, maximum applicable minimum) in append-only, +tamper-evident storage (in-DB hash chain, committed in the mutation transaction; optional +async WORM anchoring — D-14) — no mutation or deletion within retention. + +**Implements**: `cpt-cf-bss-pricing-algo-audit` + +**Touches**: +- API: `GET /v1/pricing/audit` +- DB: `pricing_audit_log` +- Entities: `AuditTrail` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture. + +Unit: + +- [ ] Materiality matrix: unset policy ⇒ material; first publish ⇒ material; below explicit threshold ⇒ auto; multi-currency any-row-trips (one row over its own-currency threshold trips); registered always-material triggers route through +- [ ] Two-person: same-principal approve rejected (even with both roles); distinct principal passes +- [ ] RBAC: each surface × role matrix; unlisted role denied on read without the preview grant + +Integration (testcontainers): + +- [ ] A material publish blocks until an independent approval; self-approval attempt returns 403 **and** lands an audit record +- [ ] Mutating a subject while its approval is `submitted` voids the approval (record closes `voided`; a fresh submit is required) +- [ ] An approve carrying a stale pinned hash is rejected (`APPROVAL_CONTENT_MISMATCH`, 409) +- [ ] An approver whose region/brand scope does not cover the pinned change is rejected (403) and the attempt audited +- [ ] A below-threshold non-first publish auto-publishes with no approver; a row in a currency with no threshold entry is material +- [ ] A threshold-policy PUT opens an always-material approval unit: the diff applies only after an **independent** FinanceReviewer approves (self-approval 403); a policy mutation while the unit pends voids it (pin semantics); in-flight submissions keep their submit-time materiality +- [ ] A backdated import without the grant → 403; with the grant but no reason → 422; a valid one lands **only after an independent approval** (always material) and produces reference rows, an audit record incl. the approval trail, and **no** window/event side effects +- [ ] An import row with an unknown region / over-precision amount / duplicate scope key is rejected by the row-shape pipeline (enumerated report) — parity with regular authoring +- [ ] A mutation on a pricing region outside the caller's authz scope → 403 + audit record +- [ ] Audit rows resist UPDATE/DELETE (role + trigger); the tamper-evidence check detects a manually corrupted row (G4 mechanism) + +API: + +- [ ] RFC 9457 mapping for the §5 codes; audit export p95 ≤ 5s / 100 records + +## 10. Non-Functional Considerations + +- **Performance**: `ScopeGuard` is a per-request claims check (no DB read); `MaterialityEvaluator` reads the prior baseline from the read model — publish-path only. Audit writes share the mutation transaction (no lost records on crash). +- **Observability / metrics**: `pricing_approvals_pending` gauge, `pricing_approval_decision_seconds`, `pricing_self_approval_attempts_total`, `pricing_denied_requests_total{surface}`, `pricing_audit_chain_verified` (G4 verification job). +- **Security & AuthZ**: this slice **is** the security surface; its own config mutations are audited and role-split per the SoD note in the AuthZ catalog — taxonomy/tax-display (`config × write`, CatalogAdmin); the approval-threshold policy (`approval_policy × write`, FinanceReviewer — never CatalogAdmin); rate-limiting/mutation quotas are a platform/gateway NFR (deferred there, PRD §15). +- **Risks & open items**: retention maximum-vs-minimum conflict (open with Legal, G5 — engine is config-driven to absorb the answer); G4 is **decided** (in-DB hash chain, D-14) — the optional external anchor cadence is an implementation knob. diff --git a/gears/bss/pricing/docs/design/06-consumer-contracts.md b/gears/bss/pricing/docs/design/06-consumer-contracts.md new file mode 100644 index 000000000..d63a10e2a --- /dev/null +++ b/gears/bss/pricing/docs/design/06-consumer-contracts.md @@ -0,0 +1,399 @@ + + + +# DESIGN — Consumer Contracts (Slice 6) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Resolve Consumer Contracts from the Read Model](#resolve-consumer-contracts-from-the-read-model) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Proration Input Contract](#proration-input-contract) + - [Billing Timing](#billing-timing) + - [Entitlement Grant Set](#entitlement-grant-set) + - [Plan-Change Contract](#plan-change-contract) + - [Rating Compatibility Contract](#rating-compatibility-contract) +- [4. States (CDSL)](#4-states-cdsl) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Proration Inputs](#proration-inputs) + - [Billing Timing on Recurring Rows](#billing-timing-on-recurring-rows) + - [Entitlement Grants](#entitlement-grants) + - [Plan Change](#plan-change) + - [Rating Compatibility](#rating-compatibility) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **frozen read-model fields downstream systems compute from** — the +contracts that make Subscriptions' proration, Billing's deferral, and Rating's resolution +deterministic without a single defaulted field: the **proration input contract** +(`billingAnchorPolicy`, the canonical `prorationBasis` enum, `creditOnDowngrade`), +**`billingTiming`** on every recurring row, the **entitlement grant set** (or its +`PlanTier`-resolved reference), the **plan-change contract** (`allowedChangeTargets`, +`comparabilityRank` — absence = no self-service change), and the **rating compatibility +contract** (`{skuId, planId, priceId}` + evaluation-policy completeness). The catalog +publishes inputs; the math and enforcement live downstream. + +**Traces to**: `cpt-cf-bss-pricing-fr-proration-input-contract`, +`cpt-cf-bss-pricing-fr-billing-timing`, `cpt-cf-bss-pricing-fr-entitlement-grant-set`, +`cpt-cf-bss-pricing-fr-plan-change-contract`, `cpt-cf-bss-pricing-fr-rating-compatibility` + +### 1.2 Purpose + +Kill the enum-drift and default-substitution failure class across three consumer seams: the +same frozen `prorationBasis` value drives Subscriptions' and Tariffs' math (one enum, owned +here, adopted verbatim); Billing's deferral derives from an explicit frozen `billingTiming`; +plan changes only happen along Finance-approved edges. Every field is publish-validated — +absence fails publish, so a consumer can rely on presence. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-subscriptions` | Computes proration/plan-change/trial/entitlements from the published inputs | +| `cpt-cf-bss-pricing-actor-billing` | Derives deferral policy from `billingTiming` | +| `cpt-cf-bss-pricing-actor-rating` | Adopts `prorationBasis` verbatim; evaluates evaluation-policy fields; resolves `{skuId, planId, priceId}` deterministically (consolidated gear — rating ADR-0002) | +| `cpt-cf-bss-pricing-actor-catalog-registry` | Defines the features/quotas/`PlanTier` policies the grant set references | +| `cpt-cf-bss-pricing-actor-finance-manager` | Authors anchor/proration/change-target values | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.9, §17.6 (consumer contracts detail), §1.4 (Glossary: `prorationBasis`, `billingAnchorPolicy`, `billingTiming`, `allowedChangeTargets`, `comparabilityRank`) +- **Design**: [01-foundation.md](./01-foundation.md) — read model + snapshot (§4.4); [02-plan-definition.md](./02-plan-definition.md) — the rows/phases these fields attach to +- **Dependencies**: Foundation, plan-definition, price-structure (Slices 1–3): the contract fields attach to their rows and freeze in their snapshot. + +### 1.5 Scope + +**In scope**: authoring + publish validation + read-model projection of the five contracts; +the canonical `prorationBasis` enum ownership; the cross-boundary (currency/region/frequency) +mid-cycle rejection marker; grant-set referential validation against the registry. + +**Out of scope**: proration **math**, plan-change execution, trial runtime, entitlement +**enforcement** (Subscriptions); deferral execution (Billing); formula evaluation (Tariffs); +the golden proration fixture content (jointly owned, gated in Slice 3's fixture registry +pattern); `PlanLink` migration (Slice 11). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-6-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| K1 | Canonical proration enum | `prorationBasis ∈ {calendar_days_actual, calendar_days_30, by_second, whole_unit, none}` — owned here, adopted **verbatim** by Tariffs; any extension is a versioned contract change | PRD §1.4 | +| K2 | Anchor month-end/UTC | `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}`; `fixed_day(d)` — and a `subscription_start` anchor under monthly-granular cycles incl. `customEveryN Months(n)` (D-20) — with a day > month length anchors on the **last day of the month**, the **anchor day preserved** across periods (independent per-period clamp: 31→28→31, no drift); all anchor math UTC | PRD §1.4; D-20 | +| K3 | Cross-boundary changes | Mid-cycle changes crossing currency/region/frequency are **not supported at launch** → cancel + new subscription; the contract publishes **no** cross-boundary credit basis; written sign-off (Subscriptions + Finance + GTM) is an open item | PRD §17.6 | +| K4 | Rank vs PlanTier | `PlanTier` alone is **not** an ordering unless published as authoritative; otherwise `comparabilityRank` is REQUIRED for any plan in self-service change | PRD §1.4 | +| K5 | Proration fixture | The joint proration golden fixture (catalog + Subscriptions + Tariffs) exists before code; publish-contract sign-off gates on it | PRD §13 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary; inherits Foundation mechanics. Not restated. + +Design-introduced names (Slice 6): + +| Name | Meaning | +|------|---------| +| `ContractFieldValidator` | Registered rules: presence/placement of the five contracts' fields at publish | +| `GrantSetResolver` | Validates the entitlement grant set (or `PlanTier` reference) against registry definitions; projects the resolved set | +| `ChangeGraphValidator` | Validates `allowedChangeTargets` (published targets only) + `comparabilityRank` presence per K4 | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s6["Slice 6 — Consumer Contracts"] + CFV["ContractFieldValidator"] + GSR["GrantSetResolver"] + CGV["ChangeGraphValidator"] + end + REG["Catalog registry
features · quotas · PlanTier policies"] + FND["Foundation (Slice 1)
ValidationPipeline · ReadModelProjector · pricingSnapshotRef"] + SUB["Subscriptions"] + BIL["Billing"] + TRF["Tariffs"] + RAT["Rating"] + REG --> GSR + CFV --> FND + GSR --> FND + CGV --> FND + FND --> SUB + FND --> BIL + FND --> TRF + FND --> RAT +``` + +**Consumed:** registry feature/quota/`PlanTier` definitions. **Produced:** the five frozen +contracts in the read model + `pricingSnapshotRef` — the fields Subscriptions/Billing/ +Tariffs/Rating compute from. + +## 2. Actor Flows (CDSL) + +### Resolve Consumer Contracts from the Read Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-contract-resolution` + +**Actor**: `cpt-cf-bss-pricing-actor-subscriptions`, `cpt-cf-bss-pricing-actor-billing`, `cpt-cf-bss-pricing-actor-rating` + +**Success Scenarios**: +- A consumer pins a committed `CatalogVersion` and reads the contract fields exactly as published (Foundation §4.4): proration inputs on recurring rows, `billingTiming`, the grant set, the change contract, `{skuId, planId, priceId}` +- Absence semantics are trustworthy: a missing `allowedChangeTargets` **means** no self-service change (fail-safe), never "unknown" + +**Error Scenarios**: +- A consumer requesting a field on a plan published before this contract existed → the field is **absent by version**, and the consumer's own fail-safe applies (no catalog-side default synthesis) + +**Steps**: +1. [ ] - `p1` - Consumer resolves via `pricingSnapshotRef` / the read-model API (Foundation `cpt-cf-bss-pricing-interface-catalog-read-model`) - `inst-cr-resolve` +2. [ ] - `p1` - No default substitution: every REQUIRED field was publish-validated present; optional fields carry defined absence semantics (fail-safe) - `inst-cr-nodefault` +3. [ ] - `p1` - **RETURN** the frozen contract set, stable for the pinned version - `inst-cr-return` + +## 3. Processes / Business Logic (CDSL) + +### Proration Input Contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-proration-inputs` + +**Input**: every recurring price row at publish +**Output**: `billingAnchorPolicy` + `prorationBasis` + `creditOnDowngrade` frozen in the read model / snapshot + +**Steps**: +1. [ ] - `p1` - All three fields REQUIRED on recurring rows; absence fails publish - `inst-pi-required` +1a. [ ] - `p1` - **`creditOnDowngrade` semantics (normative):** on a downgrade the governing value is the **source** row's flag (the row whose prepaid period is surrendered), read from the **subscription's frozen snapshot** — never the target row and never the live catalog - `inst-pi-credit-source` +1b. [ ] - `p1` - **Cross-field consistency:** `creditOnDowngrade = true` on a row with `prorationBasis = none` is a contradiction (credit granted but no basis to compute a partial period) — publish rejects it - `inst-pi-credit-none` +2. [ ] - `p1` - `prorationBasis` values per K1 — the canonical enum is **owned here**; Tariffs adopts verbatim; Subscriptions computes the amount from the same frozen value (one source, no drift) - `inst-pi-enum` +3. [ ] - `p1` - `billingAnchorPolicy` per K2: `fixed_day(d)` beyond month length anchors last-of-month; the same clamp + preserved-anchor-day rule applies to `subscription_start` under `customEveryN Months(n)` (D-20); all anchor math UTC; `customEveryN Days(n)` plans MUST carry `subscription_start` (cross-checked with Slice 2's cycle rule); the anchor math rides the joint proration/anchor fixture - `inst-pi-anchor` +4. [ ] - `p1` - **Cross-boundary marker (K3):** the contract publishes no cross-currency/region/frequency credit basis; such a mid-cycle change is rejected for in-place proration (cancel + new subscription; operator warned that in-place credit is forfeited — enforcement in Subscriptions). The published artifact is a **contract-level pair of read-model fields**: `crossBoundaryChangePolicy = cancel_plus_new` + `crossBoundaryWarningText`, projected into `pricing_read_model` (§6) - `inst-pi-crossboundary` + +### Billing Timing + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-billing-timing` + +**Input**: recurring/usage rows at publish +**Output**: explicit frozen `billingTiming` per recurring row + +**Steps**: +1. [ ] - `p1` - `billingTiming ∈ {in_advance, in_arrears}` REQUIRED on every recurring row; absence fails publish (also enforced in Slice 2's recurring-cycle rule — one rule, registered once, referenced by both) - `inst-bt-required` +2. [ ] - `p1` - Usage rows are implicitly `in_arrears` (not authored, projected constant); a hybrid MAY mix `in_advance` base + `in_arrears` usage - `inst-bt-usage` +3. [ ] - `p1` - Frozen in `pricingSnapshotRef`; Billing derives deferral policy from it — never from heuristics - `inst-bt-frozen` + +### Entitlement Grant Set + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-grant-set` + +**Input**: the plan's grant set (feature flags, quotas) or its `PlanTier`-resolved reference +**Output**: the resolved grant set in the read model + +**Steps**: +1. [ ] - `p1` - Publish fails if a referenced feature, quota, or `PlanTier` policy is **undefined in the registry** (`GrantSetResolver` referential check) - `inst-gs-referential` +2. [ ] - `p1` - Published shape per §17.6: `featureFlag: bool` entries + `quotaKey: value` entries (Subscriptions consumes them as Entitlements); semantics are not defined here - `inst-gs-shape` +3. [ ] - `p1` - A `PlanTier`-resolved reference publishes the **resolved** set (so Subscriptions provisioning does not re-derive from the taxonomy at runtime) plus the reference for auditability - `inst-gs-resolved` +3a. [ ] - `p1` - **Grant-set drift (D-27):** the registry can change a `PlanTier`'s feature/quota policy after publish — the catalog consumes the registry's tier-policy-change signal and flags every affected **published** plan `grants_divergent` in the read model (+ the `pricing.contracts.grants_divergent` alarm, Warn); remediation is a re-publish (re-resolving the set); consumers keep the frozen resolved set meanwhile — a flag, never a silent retro-change (mirrors S2 `inst-cmp-tier-drift`; the signal scope is part of the registry joint contract, PRD §15) - `inst-gs-drift` + +### Plan-Change Contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-plan-change` + +**Input**: `allowedChangeTargets` (list or rule) + `comparabilityRank` on plans participating in self-service change +**Output**: the change contract in the read model; fail-safe absence semantics + +**Steps**: +1. [ ] - `p1` - `allowedChangeTargets` entries MUST be **explicit published `planId`s** — rule-based targets are **not authorable at launch** (D-23: a rule resolves only at read time, defeating every publish-time guarantee below; the designed extension — read-time fail-safe resolution with a `partially_resolvable` marker — is §17.8 Future); a dangling target fails publish. An edge whose target is **later retired** is **inert**: Subscriptions MUST re-check the target's lifecycle state at change time (D-24) - `inst-pc-targets` +1a. [ ] - `p1` - **Mutual comparability:** for every listed target, publish validates the target carries a `comparabilityRank` (or an authoritative published `PlanTier` ordering covers both) — otherwise the runtime classification A→B is uncomputable; ranks are a single **tenant-wide scale** (authoring discipline: documented on the read model), not per-plan-local numbers - `inst-pc-mutual` +2. [ ] - `p1` - **Absence = no self-service change** (fail-safe), never any-to-any - `inst-pc-failsafe` +3. [ ] - `p1` - `comparabilityRank` REQUIRED for any plan in self-service change unless an authoritative `PlanTier` ordering is published (K4); rank semantics: higher = upgrade, lower = downgrade, equal = switch (drives proration sign/credit in Subscriptions) - `inst-pc-rank` +4. [ ] - `p1` - **Edge boundary classification (D-25):** publish classifies every change edge as `in_place` (target covers the source's `(currency, region)` set and matches frequency) or `cancel_plus_new` (crosses a K3 boundary) and **publishes the classification on the edge** — Subscriptions and the storefront disclose credit forfeiture on `cancel_plus_new` edges instead of discovering it at execution; the classification re-computes on either side's re-publish - `inst-pc-boundary` +4. [ ] - `p1` - Change-target edits are plan mutations → versioned, approvable (Slice 5 materiality applies) - `inst-pc-governed` + +### Rating Compatibility Contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-rating-compat` + +**Input**: the publishing plan's full row set +**Output**: the §17.6 rating-compatibility guarantees, checked as one registered rule bundle + +**Steps**: +1. [ ] - `p1` - Stable `{skuId, planId, priceId}` exposed on all downstream artifacts; ids never re-used across revisions (append-only rows guarantee this structurally, Foundation §4.3) - `inst-rc-ids` +2. [ ] - `p1` - Completeness cross-check (delegating to the owning slices' rules; this bundle asserts the **union**): `modelKind` + `quantitySource` + `packageSize`/`packagePrice` (Slice 3), `tierAggregationWindow`/`billingGranularity` on usage rows (Slice 3), meter injectivity (Slice 2), descriptors (Slice 2) - `inst-rc-union` +3. [ ] - `p1` - No monetary charge computed here — the contract is inputs-only (Foundation principle) - `inst-rc-nocompute` + +## 4. States (CDSL) + +No slice-owned state machine: contract fields ride the plan/price lifecycle (draft → +published → superseded/retired) owned by the Foundation and Slices 2/3. Absence semantics +(`allowedChangeTargets` missing = no self-service change) are **values**, not states. + +## 5. API Surface + +No new endpoints: the contracts are fields of the Foundation read-model API +(`cpt-cf-bss-pricing-interface-catalog-read-model`) and are authored through the Slice 2/3 +plan/price authoring surfaces. This slice contributes: + +| Concern | Where it lands | +|---------|----------------| +| Proration inputs, `billingTiming` | recurring-row fields on `POST/PATCH /v1/pricing/plans/{planId}/prices` | +| Grant set / grant reference | plan fields on `POST/PATCH /v1/pricing/plans/{planId}` | +| Change contract | plan fields on `POST/PATCH /v1/pricing/plans/{planId}` | + +**Problem responses (RFC 9457):** `PRORATION_INPUTS_MISSING` (422), +`PRORATION_INPUTS_CONTRADICTORY` (422 — `creditOnDowngrade = true` with +`prorationBasis = none`, `inst-pi-credit-none`), `BILLING_TIMING_MISSING` +(422), `GRANT_REF_UNDEFINED` (422), `CHANGE_TARGET_UNPUBLISHED` (422), +`COMPARABILITY_RANK_REQUIRED` (422). + +## 6. Data Model + +This slice adds columns to Foundation-owned tables (no new tables; `pricing_` prefix per +Foundation §3.7): + +**`pricing_price` (Slice-6 columns, recurring rows)**: + +| Column | Type | Notes | +|--------|------|-------| +| `billing_anchor_policy` | `enum` | `calendar_month \| subscription_start \| fixed_day`; + `anchor_day` (`int`, for `fixed_day`) | +| `proration_basis` | `enum` | `calendar_days_actual \| calendar_days_30 \| by_second \| whole_unit \| none` (K1) | +| `credit_on_downgrade` | `bool` | catalog-sanctioned downgrade credit eligibility | +| `billing_timing` | `enum` | `in_advance \| in_arrears`; NOT NULL on published recurring rows | + +**`pricing_plan` (Slice-6 columns)**: + +| Column | Type | Notes | +|--------|------|-------| +| `entitlement_grants` | `jsonb` | `featureFlag`/`quotaKey` entries, or the `PlanTier` reference + the resolved set | +| `allowed_change_targets` | `jsonb` | explicit `planId` list or rule; NULL = no self-service change (fail-safe) | +| `comparability_rank` | `int` | required when participating in self-service change (K4) | + +Key constraints: `CHECK (billing_timing IS NOT NULL)` enforced at the publish transition (not +on drafts); `anchor_day BETWEEN 1 AND 31` with last-of-month semantics per K2 documented on +the read model; grant/target referential checks are application-level at publish (registry / +published-plan lookups). + +**`pricing_read_model` (contract-level fields)**: `crossBoundaryChangePolicy` +(`cancel_plus_new` — the K3 marker) + `crossBoundaryWarningText` (the operator/storefront +warning that in-place credit is forfeited), projected once per contract version. + +## 7. Events & Alarms + +No new event names — contract fields ride `PlanPublished`/`PriceCreated`/`PriceUpdated` into +the warmed read model. Alarms: `pricing.contracts.grants_divergent` (Warn — a registry +tier-policy change diverged from a published plan's frozen grant set; remediation = +re-publish, D-27); `pricing.contracts.enum_drift` (Critical) — the CI-level +conformance check (K1/K5 fixtures) that Tariffs'/Subscriptions' adopted enums match the +canonical set; drift is a build-time block, the alarm covers runtime registry divergence. + +## 8. Definitions of Done + +### Proration Inputs + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-proration-inputs` + +Every recurring row **MUST** publish `billingAnchorPolicy` (month-end/UTC semantics per K2), +`prorationBasis` (the canonical K1 enum, adopted verbatim downstream), and +`creditOnDowngrade`, frozen in `pricingSnapshotRef`; `creditOnDowngrade = true` with +`prorationBasis = none` is a publish-rejected contradiction; cross-boundary mid-cycle changes +carry no credit basis and are rejected for in-place proration (cancel + new — the +`crossBoundaryChangePolicy`/`crossBoundaryWarningText` read-model fields carry the marker). + +**Implements**: `cpt-cf-bss-pricing-algo-proration-inputs`, `cpt-cf-bss-pricing-flow-contract-resolution` + +**Touches**: +- DB: `pricing_price` (proration columns) +- Entities: `ContractFieldValidator` + +### Billing Timing on Recurring Rows + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-billing-timing` + +`billingTiming` **MUST** be present on every published recurring row (absence fails publish), +usage rows are implicitly `in_arrears`, hybrids may mix, and the frozen value is Billing's +sole deferral input. + +**Implements**: `cpt-cf-bss-pricing-algo-billing-timing` + +**Touches**: +- DB: `pricing_price.billing_timing` +- Entities: `ContractFieldValidator` + +### Entitlement Grants + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-grants` + +The read model **MUST** carry the plan's grant set (or its `PlanTier`-resolved reference + +resolved set); publish **MUST** fail on a feature/quota/`PlanTier` policy undefined in the +registry. + +**Implements**: `cpt-cf-bss-pricing-algo-grant-set` + +**Touches**: +- DB: `pricing_plan.entitlement_grants` +- Entities: `GrantSetResolver` + +### Plan Change + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-plan-change` + +`allowedChangeTargets` **MUST** reference published plans only; absence **MUST** mean no +self-service change; `comparabilityRank` **MUST** be present for participating plans (unless +an authoritative `PlanTier` ordering is published); target edits are governed mutations. + +**Implements**: `cpt-cf-bss-pricing-algo-plan-change` + +**Touches**: +- DB: `pricing_plan` (change-contract columns) +- Entities: `ChangeGraphValidator` + +### Rating Compatibility + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-rating-compat` + +The publish **MUST** assert the §17.6 union — stable ids, model-kind completeness, +evaluation-policy presence on usage rows, meter mapping, descriptors — as one registered rule +bundle over the owning slices' rules; no charge computation. + +**Implements**: `cpt-cf-bss-pricing-algo-rating-compat` + +**Touches**: +- DB: `pricing_read_model` +- Entities: `ContractFieldValidator` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture. + +Unit: + +- [ ] Recurring row missing any of the three proration fields / `billingTiming` fails publish; `creditOnDowngrade=true` with `prorationBasis=none` fails publish (`PRORATION_INPUTS_CONTRADICTORY`); usage row projects `in_arrears`; `fixed_day(31)` in a 30-day month resolves last-of-month (UTC); dangling change target fails; rank-required matrix (K4); grant referential failure per undefined feature/quota/PlanTier + +Integration (testcontainers): + +- [ ] A hybrid plan publishes `in_advance` base + implicit `in_arrears` usage; both visible in the read model exactly as authored +- [ ] The published `prorationBasis` value round-trips byte-identical through snapshot → read model → consumer read (no normalization drift) +- [ ] A plan without `allowedChangeTargets` reads as no-self-service-change (field absent, not defaulted) +- [ ] Grant set resolved from `PlanTier` publishes both the reference and the resolved set +- [ ] The read model exposes `crossBoundaryChangePolicy = cancel_plus_new` + `crossBoundaryWarningText` at the contract level + +Conformance (joint, K5): + +- [ ] The shared proration golden fixture passes against the published fields (catalog side); publish-contract sign-off gates on it + +## 10. Non-Functional Considerations + +- **Performance**: all validation is publish-path; the contracts add columns to existing read-model rows — no extra read-path lookups. +- **Observability / metrics**: `pricing_contract_validation_failures_total{contract}`; the conformance-fixture status gauge (shared with Slice 3's registry). +- **Security & AuthZ**: contract fields are plan/price mutations — Slice 5 RBAC + materiality apply (a change-target edit can widen who may move where; it is governed). +- **Risks & open items**: enum drift across Subscriptions/Tariffs (PRD risk — mitigated by K1 ownership + K5 fixtures before code); cross-boundary cancel+new written sign-off open (K3; Subscriptions + Finance + GTM); the proration fixture is jointly owned and MUST exist before implementation (PRD §13 gate). diff --git a/gears/bss/pricing/docs/design/07-pricewindow-linkage.md b/gears/bss/pricing/docs/design/07-pricewindow-linkage.md new file mode 100644 index 000000000..73d4ee0cb --- /dev/null +++ b/gears/bss/pricing/docs/design/07-pricewindow-linkage.md @@ -0,0 +1,470 @@ + + + +# DESIGN — PriceWindow Linkage, Coverage & Grandfathering (Slice 7) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Execute a Grandfathering Cutover](#execute-a-grandfathering-cutover) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Publish-Time Window Coverage](#publish-time-window-coverage) + - [Future-Gap Detection](#future-gap-detection) + - [Sellability Gate](#sellability-gate) + - [Grandfathering Eligibility Resolution](#grandfathering-eligibility-resolution) + - [Grandfathering Cutover (atomic unit)](#grandfathering-cutover-atomic-unit) +- [4. States (CDSL)](#4-states-cdsl) + - [Price Window State Machine](#price-window-state-machine) + - [Grandfathered Row Eligibility State Machine](#grandfathered-row-eligibility-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Window Coverage](#window-coverage) + - [Window Lifecycle](#window-lifecycle) + - [Future Gaps](#future-gaps) + - [Sellability](#sellability) + - [Grandfathering](#grandfathering) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **time axis of published prices** — including the `PriceWindow` +machinery itself (D-03): the window tables and state machine, **scheduling**, the UTC +**activation/expiration job** (a coordination-lease singleton), and the `PriceWindow*` +**event emission** from the gear outbox; plus the publish-time **window-coverage check** +(every billable row linkable to an active/scheduled `PriceWindow` on its canonical scope key, +base `priceOverlay`), **future-gap detection** across scheduled windows, the **sellability +gate** inputs (active window + committed `CatalogVersion` + `availableFrom`/`availableTo` + +plan lifecycle state + the GA-gate flags), the **grandfathering eligibility** read-model +surface (`priceEligibility`, `cohort`, `grandfatherUntil`, most-specific-wins), and the +**grandfathering cutover** as one atomic approval unit. The legacy **effective-dating +price-windows use case is consolidated here** (PRD §15 — decided 2026-07-10): windows live +in this gear's database, so the cutover's multi-window unit is a **local ACID transaction**, +not a cross-component protocol; the UC document remains scenario source material. + +**Traces to**: `cpt-cf-bss-pricing-fr-pricewindow-coverage`, +`cpt-cf-bss-pricing-fr-future-gap-coverage`, `cpt-cf-bss-pricing-fr-sellability-gate`, +`cpt-cf-bss-pricing-fr-grandfathering-eligibility` + +### 1.2 Purpose + +Guarantee Tariffs step 2 always resolves: no billable row publishes without window coverage, +no gap between scheduled windows silently fails rating for everyone inside it, nothing sells +before it is both windowed and version-addressable, and a legacy price survives a cutover as +an immutable, concurrently-active, deterministically-selected row — with a bounded or +indefinite lifetime the operator controls via `grandfatherUntil`. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Schedules/cancels windows (owned here), runs cutovers | +| `cpt-cf-bss-pricing-actor-rating` | Resolves the active window per scope key at `t` (step 2); applies most-specific-wins | +| `cpt-cf-bss-pricing-actor-subscriptions` | Enforces the sellability gate jointly; re-binds at `grandfatherUntil` expiry | +| `cpt-cf-bss-pricing-actor-finance-reviewer` | Approves the cutover (one approval unit; Slice 5) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.5, §17.5 (grandfathering-cutover mechanism), §1.4 (Glossary: `priceEligibility`, `grandfatherUntil`), §17.3 (window-coverage + sellability composition rules) +- **Design**: [01-foundation.md](./01-foundation.md) — scope key (§4.1), immutability + cutover mechanics (§4.3); [04-currency-tax.md](./04-currency-tax.md) — `not_sellable_ga` input to the gate +- **Dependencies**: Foundation + Slices 2/3 (rows to cover); Slice 5 (the cutover approval unit); Slice 11's retirement invokes **this slice's** window-cancellation flow. The legacy effective-dating UC (`UC-effective-dating-price-windows-202601121200`) is **consolidated into this slice** (D-03) and retained as scenario source only. + +### 1.5 Scope + +**In scope**: the `PriceWindow` entity, state machine, scheduling/cancellation API, the UTC +activation/expiration singleton job, and `PriceWindow*` event emission (consolidated, D-03); +publish-time coverage on the base-`priceOverlay` scope key; future-gap rejection; the +sellability gate **inputs** (joint rule with Subscriptions); `priceEligibility` + `cohort` + +`grandfatherUntil` read-model exposure with most-specific-wins documentation; the cutover +transaction (shorten + two schedules as one **local ACID** approval unit); the +`grandfatherUntil` expiry signal. + +**Out of scope**: the scheduler/timeline **UI** (Frontend DESIGN); FX rate-lock from the +legacy UC (**rejected here** — the catalog performs no FX; Tariffs/PLAL owns rates, Future +scope); subscription/revenue impact preview from the legacy UC (needs Subscriptions data — +out of catalog scope, joint item if revived); the purchase-time enforcement itself +(Subscriptions, joint rule); Tariffs' step-2 resolution algorithm (consumes what we publish). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set (UTC everywhere). Slice-7-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| W1 | Window SoR | `PriceWindow` rows + their state machine are **owned by this slice** (consolidated per D-03 / ADR `cpt-cf-bss-pricing-adr-pricewindow-consolidation`; PRD §15 answered) — same gear, same database as the price rows: coverage checks read the owned tables directly (no mirror), and multi-window units commit in one local transaction | PRD §2.1; D-03 | +| W2 | Non-overlap key | Window non-overlap is per **canonical scope key** (ADRs `cpt-cf-bss-pricing-adr-canonical-scope-key`, `…-grandfathering-cohort-axis`); a grandfathered generation + successor — and any number of prior generations (distinct `cohort`s) — at the same `t` are distinct keys, never an overlap violation | PRD §2.2 | +| W3 | Most-specific-wins | When several eligibility classes hold active windows on the same remaining axes: `existing_grandfathered` > `new_subscriptions_only` > `all_subscriptions` (applied by Tariffs step 2 **after** eligibility matching). Class ordering only — **within** `existing_grandfathered` the generation is selected by the subscription's bound `cohort` (the cohort of its pinned price id) → unique row | PRD §1.4 | +| W4 | Cutover atomicity | Shorten current `all_subscriptions` window `effectiveTo` + schedule the grandfathered copy + schedule the successor = **one approval unit**; active windows are shortened, not cancelled; `PriceWindowCancelled` only for not-yet-active windows of the old key | PRD §17.5 | +| W5 | Deferred publish | "Publish at T" is out of launch scope; `availableFrom`/`availableTo` are purchasability dates validated against coverage, not a publish scheduler | PRD §6.5 | + +### 1.7 Naming & Design-Introduced Names + +Reuses the PRD glossary; inherits Foundation mechanics. Not restated. + +Design-introduced names (Slice 7): + +| Name | Meaning | +|------|---------| +| `CoverageChecker` | Publish-time rule: every billable row's scope key has an active/scheduled window; gap detection across scheduled windows | +| `SellabilitySurface` | The read-model composite the joint gate evaluates: active-window flag + committed-version flag + `availableFrom/To` + plan lifecycle state + the GA-gate flags (`not_sellable_ga`, prepaid-execution) | +| `CutoverOrchestrator` | Builds the W4 atomic unit (shorten + grandfathered copy + successor) as one approval + one **local ACID** transaction | +| `EligibilityExpirySignal` | The published signal that a row's `grandfatherUntil` passed — Subscriptions re-binds at next renewal | +| `WindowScheduler` | The owned scheduling/cancellation surface: creates `scheduled` windows (overlap-validated per scope key), cancels not-yet-active ones, adjusts a future `effectiveTo` (D-03) | +| `WindowActivationJob` | Coordination-lease singleton: flips `scheduled → active` at `effectiveFrom` and `active → expired` at `effectiveTo` (UTC, idempotent, ordered per `(tenant, plan)`), emitting `PriceWindowActivated`/`Expired` from the outbox | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s7["Slice 7 — Window Linkage & Grandfathering"] + WT["pricing_price_window store + WindowActivationJob
state machine · PriceWindow* outbox (owned, D-03)"] + CC["CoverageChecker"] + SS["SellabilitySurface"] + CO["CutoverOrchestrator"] + ES["EligibilityExpirySignal"] + end + FND["Foundation (Slice 1)
ScopeKey · ValidationPipeline · ReadModelProjector"] + SUB["Subscriptions
joint sellability gate · renewal re-bind"] + TRF["Tariffs step 2
window resolution · most-specific-wins"] + CC --> WT + CO --> WT + CC --> FND + CO --> FND + FND --> TRF + SS --> SUB + ES --> SUB +``` + +**Owned:** the `pricing_price_window` store + state machine, the `WindowActivationJob`, and +`PriceWindowScheduled/Activated/Cancelled/Expired` emission (D-03; previously consumed from the effective-dating UC). +**Produced:** the coverage guarantee, the sellability surface, eligibility fields + +most-specific-wins semantics, the cutover unit, the expiry signal. + +## 2. Actor Flows (CDSL) + +### Execute a Grandfathering Cutover + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-grandfathering-cutover` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` (approval via Slice 5 — a cutover is always material) + +**Success Scenarios**: +- One approval unit atomically: shortens the current `all_subscriptions` window to the cutover instant, schedules the immutable `existing_grandfathered` copy as a **new `cohort` generation** (`cohort` = the cutover instant; prior generations' rows and windows are untouched), schedules the `all_subscriptions` successor — **no coverage gap opens** at any instant; cutovers are **repeatable** per key (ADR-0002) +- Optionally sets `grandfatherUntil` (UTC) on the copy; null = indefinite + +**Error Scenarios**: +- A composed unit whose parts no longer form a gap-free set at compose/commit (copy-window bound violation per `inst-co-bounds`, or the owned window state changed between compose and commit) → rejected (`CUTOVER_GAP`) +- A cutover instant in the past at submit, or an instant that passes while the approval pends → rejected (`CUTOVER_INSTANT_PASSED`, 422); the unit is recomposed +- Attempt to supersede or reprice an `existing_grandfathered` row → rejected (Foundation §4.3; only tightening `grandfatherUntil` is allowed, as a material change) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans/{planId}/cutovers (cutover instant, successor row ref, optional `grandfatherUntil`) - `inst-gc-api` +2. [ ] - `p1` - `CutoverOrchestrator` composes the W4 unit and validates gap-freeness across the three window operations **before** submission; the cutover instant MUST be in the future at submit **and** still in the future at approval commit (`CUTOVER_INSTANT_PASSED` otherwise) - `inst-gc-compose` +3. [ ] - `p1` - Approval (Slice 5, material) → **one local ACID transaction** over the slice-owned window tables (D-03): shorten `effectiveTo` + two window schedules + the two new rows commit or roll back together — no cross-component protocol, no partial state. The grandfathered copy and the successor **pass the Foundation validation pipeline and the commit requests `CatalogVersion` addressability exactly as a supersession publish does** (PRD §17.5) — the successor is sellable only after `CatalogVersionPublished` + warm-completion. Events: `PriceCreated` ×2 (copy + successor) + `PriceWindowScheduled` ×2; `PriceWindowExpired` fires at cutover; `PriceWindowCancelled` only for not-yet-active windows of the old key - `inst-gc-commit` +4. [ ] - `p1` - **RETURN** 202 (cutover scheduled); the grandfathered copy is immutable in price from birth - `inst-gc-return` + +## 3. Processes / Business Logic (CDSL) + +### Publish-Time Window Coverage + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-window-coverage` + +**Input**: a publishing plan's billable rows + their window linkage refs +**Output**: pass, or a fail-closed violation directing the operator to schedule a window + +**Steps**: +1. [ ] - `p1` - Every billable row's **canonical scope key** (resolved on the base `priceOverlay`) MUST have an active or scheduled `PriceWindow`; absence fails publish — no silent fallback (Tariffs step 2 would resolve nothing) - `inst-wc-required` +2. [ ] - `p1` - Distinct keys hold windows independently: a hybrid's `recurring`/`usage`/`one_time_setup` components and a grandfathered row + successor each carry their own coverage (W2) - `inst-wc-perkey` +3. [ ] - `p1` - `availableFrom`/`availableTo` (when set) validate **against** window coverage: a purchasability interval reaching outside all coverage fails publish (W5 — dates gate purchase, they do not schedule publish) - `inst-wc-availability` + +### Future-Gap Detection + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-future-gap` + +**Input**: ≥ 2 active/scheduled windows on one scope key +**Output**: pass, or the uncovered interval named + +**Steps**: +1. [ ] - `p1` - Sort windows by `effectiveFrom`; any uncovered interval between one window's end and the next's start over billable periods → reject, naming `[gapStart, gapEnd)` and the scope key - `inst-fg-detect` +2. [ ] - `p1` - The check runs at publish **and** inside every window-mutating operation (schedule, cancel, `effectiveTo` adjustment, cutover, retirement-triggered cancellation) — windows are slice-owned (D-03), every mutation goes through `WindowScheduler`/`CutoverOrchestrator`, and there is **no side door**: the window tables carry the same REVOKE + column-whitelist trigger discipline as `pricing_price`, so a gap can never be introduced past validation - `inst-fg-when` + +### Sellability Gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-sellability` + +**Input**: a purchase-time check for `(planId, scope key, t)` — executed by Subscriptions against our surface +**Output**: sellable / not-sellable with the failing predicate + +**Steps**: +1. [ ] - `p1` - The read model exposes per key: **active**-window-at-`t` (scheduled is NOT sellable), committed-`CatalogVersion` addressability (pending fan-out is NOT sellable), `availableFrom`/`availableTo`, plan **lifecycle state** (`retired` is NOT sellable — Slice 11 retirement blocks through this gate), and the **GA-gate flags** (`not_sellable_ga` — Slice 4, evaluated **per scope key / market**, not per plan; the prepaid-execution gate — Slice 10 — is published as the same flag mechanism) - `inst-sg-surface` +2. [ ] - `p1` - The gate is a **joint rule with Subscriptions**: purchase MUST NOT create while any predicate fails; catalog publishes the surface, Subscriptions enforces at order time - `inst-sg-joint` +2a. [ ] - `p1` - **Renewal is not a purchase:** the gate governs the creation of **new** subscriptions only — a renewal of an existing subscription is never blocked by it (a retired plan or a passed `availableTo` does not kill in-flight renewals; their lifecycle is owned by the grandfathering/migration mechanics) - `inst-sg-renewal` +2b. [ ] - `p1` - **Bundle conjunction:** for a `bundle`-type plan the gate is the **conjunction** — every referenced component key passes all five predicates at `t` (and, for `own_price` bundles, the bundle's own rows too), plus the bundle's own `availableFrom`/`availableTo`; the `SellabilitySurface` exposes the frozen component key set for this walk (composition rules normative in Slice 8, `inst-bc-sellability`) - `inst-sg-bundle` +3. [ ] - `p1` - All five predicates are point-in-time evaluable from the pinned read model (no live catalog query at order time beyond the version-pinned read) - `inst-sg-pinned` +4. [ ] - `p1` - **Launch boundary — segment plans are not self-service.** All five predicates are **payer-independent**: the gate does not check group membership, so a plan whose pricing targets a specific customer group as a **separate `planId`** (different tier structure — the F-88 Future path) MUST NOT be sold through a self-service checkout at launch; its sales channel is operator-only (RBAC is the gate). Segment **discounts** need no plan of their own — they are `customerGroup` overlays resolved server-side from the authenticated payer (Slice 9), so there is no discounted `planId` to leak - `inst-sg-segment-boundary` +5. [ ] - `p2` - **Group-scoped plan eligibility (designed, implementation-gated — F-88).** The designed extension that lifts the launch boundary: a plan-level `eligibleCustomerGroups` set (taxonomy-validated, snapshot-frozen; authored via Slice 9) and a **sixth predicate** — `payer's resolved group ∈ eligibleCustomerGroups(plan) at t`. This is the first **payer-dependent** predicate: `sellability(plan, t)` becomes `sellability(plan, payer, t)`, the payer identity derives from the authenticated caller's claims (never a request parameter), and sellability responses MUST NOT be cached payer-agnostically once it lands — implementations of the five-predicate gate MUST keep the surface extensible for this (no global sellability cache keyed by plan alone). Industry precedent: catalog/price-book assignment per buyer (Shopify B2B catalogs, Salesforce Price Books, Kill Bill PriceOverlays). Activation requires reopening F-88 (Product) + the Slice 9 policy decisions - `inst-sg-eligibility-gated` + +### Grandfathering Eligibility Resolution + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-eligibility` + +**Input**: the eligibility axes on published rows +**Output**: the read-model fields + semantics Tariffs step 2 resolves deterministically + +**Steps**: +1. [ ] - `p1` - The read model exposes `priceEligibility` (`all_subscriptions | new_subscriptions_only | existing_grandfathered`), the row's `cohort` (generation; `none` unless grandfathered), and `grandfatherUntil` (UTC, null = indefinite) per row - `inst-el-fields` +2. [ ] - `p1` - **Most-specific-wins (W3)** documented on the read model: after eligibility matching, `existing_grandfathered` > `new_subscriptions_only` > `all_subscriptions` — class ordering only; new subscriptions never bind to a grandfathered row - `inst-el-msw` +2a. [ ] - `p1` - **Generation selection (ADR-0002):** within `existing_grandfathered`, Tariffs resolves the row whose `cohort` equals the cohort of the subscription's **pinned price id** (`pricingSnapshotRef` already pins it — no separate binding store); generations coexist, each with its own window and `grandfatherUntil`; the resolved row is always unique - `inst-el-generation` +3. [ ] - `p1` - The successor row in a cutover carries `all_subscriptions`, so a grandfathered subscription re-bound at expiry resolves to it naturally — regardless of which generation expired - `inst-el-successor` +4. [ ] - `p1` - `EligibilityExpirySignal`: a bound subscription renewing on/after **its generation's** `grandfatherUntil` MUST be signalled no-longer-eligible; the expiry flag is **derived at read time** (`now ≥ grandfatherUntil` against the published bound) — never stored, no job, no new event (§7 holds); Subscriptions executes the re-bind at next renewal — the catalog never rebinds - `inst-el-expiry` + +### Grandfathering Cutover (atomic unit) + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-cutover` + +**Input**: current `all_subscriptions` row/window + successor definition + cutover instant +**Output**: the W4 three-operation unit, gap-free, as one approval + one transaction + +**Steps**: +1. [ ] - `p1` - **Shorten** the current window's `effectiveTo` to the cutover (active windows are shortened, never cancelled) - `inst-co-shorten` +2. [ ] - `p1` - **Schedule** the `existing_grandfathered` **copy** (immutable in price; carries the pre-cutover amount) effective at cutover for pre-cutover subscribers — on `cohort` = the cutover instant, i.e. a **new generation key**; prior generations are untouched, and an instant equal to an existing generation's `cohort` is rejected at compose (`DUPLICATE_SCOPE_KEY`) - `inst-co-copy` +3. [ ] - `p1` - **Schedule** the `all_subscriptions` successor effective at cutover - `inst-co-successor` +4. [ ] - `p1` - Validate gap-freeness across all three (every instant covered for the `all_subscriptions` key and the **new generation's** key; prior generations' coverage is untouched by construction); commit as one transaction under one approval; the only later mutation permitted on a copy is **tightening** its `grandfatherUntil` (material change, Slice 5) - `inst-co-atomic` +5. [ ] - `p1` - **Bound consistency (normative, D-04):** the grandfathered copy carries two clocks — its window and `grandfatherUntil` — and the window MUST cover through **`grandfatherUntil` + the longest billing cycle sold on that key** (open-ended when null). The margin exists because re-bind happens only at the **next renewal** after expiry: a bound period that started before `grandfatherUntil` keeps rating (usage/arrears) against the generation's key until that renewal — with the margin, no legitimate bound interval is ever uncovered; without it, a window ending at `grandfatherUntil` strands subscribers for up to one full cycle. The row sells nothing new past expiry (new subscriptions never bind grandfathered rows), so the margin leaks nothing. Cutover validation rejects a violating unit, and a later `effectiveTo` adjustment below the bound is likewise rejected (`WINDOW_HISTORICAL_IMMUTABLE`/`CUTOVER_GAP` semantics) - `inst-co-bounds` +6. [ ] - `p1` - **One pending unit per key:** at most one pending approval unit (cutover or supersession) may exist per canonical scope key — a second submit on the same key while one is `submitted` returns 409 (`PENDING_CHANGE_UNIT_EXISTS`); a cutover unit pends **both** keys it touches (the `all_subscriptions` key and the **new generation's** key — prior generations are not pended). ETag protects rows, this rule protects **change units** from approving contradictory window operations - `inst-co-single-pending` +7. [ ] - `p1` - **Retirement unwind (D-05):** plan retirement with a live cutover unit **unwinds** it inside the retirement transaction (one ACID scope, D-03): the predecessor window's `effectiveTo` is restored to its recorded pre-cutover value (a legal future-`effectiveTo` adjustment), the scheduled copy/successor windows are cancelled (`PriceWindowCancelled` each), and the unit closes as `unwound` (audit keeps both the approval and the unwind); a merely `submitted` unit is voided per the standard Slice 5 pin semantics. Without the unwind, the shortened predecessor + cancelled schedules would strand in-flight subscribers uncovered at the cutover instant — the trailing void no gap check can see. Retirement with a live cutover is **always material** (registered into the Slice 5 evaluator); prior generations are untouched (active windows run out per retirement semantics) - `inst-co-retirement-unwind` + +## 4. States (CDSL) + +### Price Window State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-price-window` + +**States**: scheduled, active, expired, cancelled +**Initial State**: scheduled (created by `WindowScheduler`/`CutoverOrchestrator`; overlap-validated per canonical scope key at creation) + +**Transitions**: +1. [ ] - `p1` - **FROM** scheduled **TO** active **WHEN** `now ≥ effectiveFrom` (the `WindowActivationJob` flips it and emits `PriceWindowActivated`; idempotent, ordered per `(tenant, plan)`) - `inst-ws-activate` +2. [ ] - `p1` - **FROM** active **TO** expired **WHEN** `now ≥ effectiveTo` (job flips it and emits `PriceWindowExpired`; an open-ended window — `effectiveTo = null` — never expires; **no fallback pricing exists**: a key without a successor window fails closed downstream, per the coverage doctrine) - `inst-ws-expire` +3. [ ] - `p1` - **FROM** scheduled **TO** cancelled **WHEN** cancelled before activation (retirement flow, cutover unwind, or operator cancellation; emits `PriceWindowCancelled`); an **active or historical window is never cancelled or deleted** — active windows are only shortened via `effectiveTo` - `inst-ws-cancel` +4. [ ] - `p1` - **Historical immutability:** once `effectiveFrom` has passed, `effectiveFrom` and the window↔price binding are immutable; the only permitted mutation of an `active` window is moving its **future** `effectiveTo` (shorten/extend, overlap- and coverage-validated — cutover's shorten uses this path); `expired`/`cancelled` windows are immutable history (7y retention with the audit store) - `inst-ws-immutable` + +### Grandfathered Row Eligibility State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-grandfathered` + +**States**: active_indefinite (`grandfatherUntil = null`), active_bounded, expired +**Initial State**: per the cutover's `grandfatherUntil` (null → active_indefinite). One machine +per generation row — generations expire independently (ADR-0002) + +**Transitions**: +1. [ ] - `p1` - **FROM** active_indefinite **TO** active_bounded **WHEN** `grandfatherUntil` is set (tightening only; material change) - `inst-gs-bound` +2. [ ] - `p1` - **FROM** active_bounded **TO** active_bounded **WHEN** `grandfatherUntil` is tightened further (never loosened, never the price) - `inst-gs-tighten` +3. [ ] - `p1` - **FROM** active_bounded **TO** expired **WHEN** `now ≥ grandfatherUntil`: the `EligibilityExpirySignal` raises (a read-time-derived condition — no stored state flips, no job); bound subscriptions re-bind at their next renewal (Subscriptions); the row itself stays immutable history - `inst-gs-expire` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST` | `/v1/pricing/prices/{priceId}/windows` | Schedule a window (overlap-validated; D-03 owned surface) | client idempotency key | +| `PATCH` | `/v1/pricing/price-windows/{windowId}` | Adjust a future `effectiveTo` (shorten/extend; coverage-validated) | ETag | +| `DELETE` | `/v1/pricing/price-windows/{windowId}` | Cancel a not-yet-active window (emits `PriceWindowCancelled`) | — | +| `POST` | `/v1/pricing/plans/{planId}/cutovers` | Compose + submit the atomic grandfathering cutover — **single- or multi-key** (D-28): the payload carries a scope-key selector; all selected keys cut over at **one instant** as **one approval unit** / one local ACID transaction (per-key generations created; the unit pends every touched key; the S5 per-row hash pin covers the whole set) | per `(planId, key-set hash, cutover instant)` | +| `PATCH` | `/v1/pricing/prices/{priceId}/grandfather-until` | Tighten `grandfatherUntil` (material change) | ETag | +| `GET` | `/v1/pricing/plans/{planId}/sellability?at=¤cy=®ion=` | The sellability surface for the joint gate | — | +| `GET` | `/v1/pricing/plans/{planId}/coverage` | Coverage/gap report per scope key (operator remediation) | — | + +**Problem responses (RFC 9457):** `WINDOW_COVERAGE_MISSING` (422, names the scope key), +`WINDOW_GAP` (422, names `[gapStart, gapEnd)`), `WINDOW_OVERLAP` (409 — the scheduled/adjusted +window overlaps an existing one on the same canonical scope key), `WINDOW_HISTORICAL_IMMUTABLE` +(409 — mutation of a past `effectiveFrom`, an expired/cancelled window, or the window↔price +binding), `WINDOW_NOT_CANCELLABLE` (409 — DELETE on an active/historical window), +`CUTOVER_GAP` (422), +`CUTOVER_INSTANT_PASSED` (422 — instant in the past at submit or at approval commit), +`PENDING_CHANGE_UNIT_EXISTS` (409 — a pending unit already holds one of the touched keys), +`GRANDFATHER_LOOSEN_FORBIDDEN` (422), `GRANDFATHERED_ROW_IMMUTABLE` (409), +`AVAILABILITY_OUTSIDE_COVERAGE` (422). + +## 6. Data Model + +Slice-owned window store (windows are owned here per D-03/W1; `pricing_` prefix per Foundation §3.7): + +**`pricing_price_window`** (PK `window_id`; tenant-scoped, SecureORM): + +| Column | Type | Notes | +|--------|------|-------| +| `window_id` | `uuid` | PK | +| `tenant_id` | `uuid` | RLS scope | +| `price_id` | `uuid` | FK `pricing_price` — binds the window to its row (and thereby its canonical scope key); immutable after creation | +| `effective_from` / `effective_to` | `timestamptz` | UTC, half-open `[from, to)`; `effective_to = null` = open-ended | +| `state` | `enum` | `scheduled \| active \| expired \| cancelled` (state machine §4) | +| `reason_code` | `string` | operator-supplied change reason (audit; from the legacy UC scenarios) | +| `created_by` / `created_at`, `activated_at` / `expired_at` / `cancelled_at` | — | audit timestamps | + +**`pricing_price` (Slice-7 columns)** — `price_eligibility` (scope-key axis, Foundation) and +`grandfather_until` (`timestamptz`, tighten-only) are already Foundation-declared; this slice +owns their **semantics + validation** and the projected eligibility/expiry flags in +`pricing_read_model`. + +**Cutover** — not a table: an approval-unit composition over existing rows/windows, +recorded in `pricing_approval` (Slice 5) with the three-operation payload, and auditable via +`pricing_audit_log`. + +Key constraints: `grandfather_until` may only decrease (application-enforced tighten-only, +audited); a grandfathered generation's window `effective_to` MUST stay ≥ `grandfather_until` ++ the longest billing cycle sold on the key (D-04 — enforced at cutover and on every +`effectiveTo` adjustment); half-open intervals `[from, to)` — adjacent windows share a boundary legally; +**non-overlap per canonical scope key** enforced inside every mutation (`WINDOW_OVERLAP`); +historical immutability via the same `REVOKE` + column-whitelist trigger discipline as +`pricing_price` (permitted UPDATEs: state-machine transitions, future `effective_to` +adjustment; DELETE always rejected — cancel is a state, not a deletion); coverage/gap checks +read the owned table directly (no mirror — D-03). + +## 7. Events & Alarms + +No new frozen event names — the manifest §4.1 `PriceWindow*` set is now **produced by this +gear's outbox** (D-03; previously consumed from the effective-dating UC): `PriceWindowScheduled` +on schedule, `PriceWindowActivated`/`PriceWindowExpired` from the `WindowActivationJob` +(ordered per `(tenant, plan)`, idempotency-keyed, at-least-once), `PriceWindowCancelled` on +cancellation. The cutover emits `PriceCreated` ×2 (copy + successor) + `PriceWindowScheduled` +×2 / `PriceWindowExpired` / `PriceWindowCancelled` per W4. +Alarms: `pricing.window.activation_overdue` (Warn — a `scheduled` window past `effectiveFrom` +(or an `active` one past `effectiveTo`) not yet transitioned beyond the job SLO; the lease +singleton is stalled), `pricing.grandfather.expiry_signal_backlog` (Warn — +expired-but-still-bound rows reported back by Subscriptions past one renewal cycle; the exact +re-bind feedback mechanism is part of the §10 joint contract). + +## 8. Definitions of Done + +### Window Coverage + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-window-coverage` + +Publish **MUST** fail for any billable row whose canonical scope key (base `priceOverlay`) lacks +an active/scheduled window, directing the operator to schedule one; distinct keys carry +independent coverage; `availableFrom`/`availableTo` validate against coverage. + +**Implements**: `cpt-cf-bss-pricing-algo-window-coverage` + +**Touches**: +- API: `GET /v1/pricing/plans/{planId}/coverage` +- DB: `pricing_price_window` +- Entities: `CoverageChecker` + +### Window Lifecycle + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-window-lifecycle` + +The slice **MUST** own the full `PriceWindow` lifecycle (D-03): schedule (overlap-validated +per canonical scope key), activate/expire via the coordination-lease singleton at the UTC +boundaries (idempotent; events ordered per `(tenant, plan)`; within the activation SLO), +cancel only not-yet-active windows, adjust only a future `effectiveTo`; historical windows +are immutable (7y retention); `PriceWindow*` events emit from the gear outbox under the +frozen manifest names. **No fallback pricing exists** — expiry without a successor fails +closed downstream. + +**Implements**: `cpt-cf-bss-pricing-state-price-window` + +**Touches**: +- API: `POST /v1/pricing/prices/{priceId}/windows`, `PATCH/DELETE /v1/pricing/price-windows/{windowId}` +- DB: `pricing_price_window`, `pricing_outbox` +- Entities: `WindowScheduler`, `WindowActivationJob` + +### Future Gaps + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-future-gap` + +With ≥ 2 scheduled windows on one scope key, publish **MUST** reject any uncovered billable +interval, naming the gap and the key. + +**Implements**: `cpt-cf-bss-pricing-algo-future-gap` + +**Touches**: +- DB: `pricing_price_window` +- Entities: `CoverageChecker` + +### Sellability + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-sellability` + +The read model **MUST** expose the six sellability predicates (active window — not merely +scheduled; committed version; availability dates; plan lifecycle state — `retired` blocks; +the GA-gate flags: `not_sellable_ga` / prepaid-execution; the registry `sellable` flag — +standalone lines only, D-46) point-in-time evaluable +from a pinned version; the purchase-time gate is a joint rule enforced by Subscriptions. For a +`bundle`-type plan the surface additionally exposes the frozen component key set and the gate +evaluates the **conjunction** over it on predicates (1)–(5) — components are exempt from (6) +(own rows too for `own_price`; Slice 8). + +**Implements**: `cpt-cf-bss-pricing-algo-sellability` + +**Touches**: +- API: `GET /v1/pricing/plans/{planId}/sellability` +- DB: `pricing_read_model` +- Entities: `SellabilitySurface` + +### Grandfathering + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-grandfathering` + +The read model **MUST** expose `priceEligibility` + `cohort` + `grandfatherUntil` with +most-specific-wins class semantics and generation selection by the pinned price id's cohort +(unique resolved row; new subscriptions never bind grandfathered); the cutover **MUST** be one +gap-free atomic approval unit (shorten + copy-as-new-generation + successor), **repeatable** +per key — prior generations untouched; each copy is immutable in price with tighten-only +`grandfatherUntil`; a generation's expiry raises the re-bind signal executed by Subscriptions +at renewal. + +**Implements**: `cpt-cf-bss-pricing-flow-grandfathering-cutover`, `cpt-cf-bss-pricing-algo-eligibility`, `cpt-cf-bss-pricing-algo-cutover`, `cpt-cf-bss-pricing-state-grandfathered` + +**Touches**: +- API: `POST /v1/pricing/plans/{planId}/cutovers`, `PATCH /v1/pricing/prices/{priceId}/grandfather-until` +- DB: `pricing_price` (eligibility axes), `pricing_price_window`, `pricing_approval` +- Entities: `CutoverOrchestrator`, `EligibilityExpirySignal` + +## 9. Acceptance Criteria + +Delta over the Foundation testing architecture. + +Unit: + +- [ ] Coverage matrix per key (hybrid components covered independently); gap detection across 2/3 scheduled windows incl. touching-boundary (no false positive at `effectiveTo = next.effectiveFrom`); availability-outside-coverage rejection; most-specific-wins ordering; tighten-only `grandfatherUntil` (loosen and price change rejected); copy-window bound (`effectiveTo ≥ grandfatherUntil + longest sold cycle`) rejected at cutover and on `effectiveTo` adjustment (D-04) + +Integration (testcontainers): + +- [ ] Publishing a billable row without a window fails (`WINDOW_COVERAGE_MISSING`); scheduling a window via the owned API then re-publishing passes +- [ ] A scheduled window activates at `effectiveFrom` within the job SLO and `PriceWindowActivated` emits ordered per `(tenant, plan)`; a killed-and-restarted job (lease takeover) activates exactly once (idempotent) +- [ ] Overlap on the same canonical scope key is rejected at scheduling (`WINDOW_OVERLAP`); adjacent windows (`effectiveTo = next.effectiveFrom`) pass +- [ ] Mutating a historical window (past `effectiveFrom`, or expired/cancelled) is rejected (`WINDOW_HISTORICAL_IMMUTABLE`); DELETE of an active window is rejected (`WINDOW_NOT_CANCELLABLE`); cancelling a scheduled window emits `PriceWindowCancelled` +- [ ] A cutover produces: shortened current window, scheduled grandfathered copy (new `cohort` generation) + successor, no instant uncovered for either touched key; the copy rejects supersession/reprice +- [ ] A **second** cutover on the same remaining axes produces a second coexisting generation: three concurrently-active rows (two generations + successor), each cohort's subscription resolving its own generation's price; a cutover instant equal to an existing generation's `cohort` is rejected at compose +- [ ] At a generation's `grandfatherUntil` passing, the expiry signal appears in the read model for that generation only (siblings unaffected); the row remains readable immutable history +- [ ] Sellability: scheduled-but-not-active window → not sellable; pending (uncommitted) version → not sellable; retired plan / out-of-dates / GA-flagged market → not sellable; all five predicates satisfied → sellable +- [ ] Bundle conjunction: one unsellable component key (any failing predicate) → the bundle is not sellable; an `own_price` bundle additionally requires its own rows sellable +- [ ] The cutover transaction is atomic: a simulated failure on the successor-schedule step rolls back the shorten and both schedules (no partial window state at any instant) + +API: + +- [ ] RFC 9457 mapping for the §5 codes; the coverage report names every uncovered key/interval + +## 10. Non-Functional Considerations + +- **Performance**: coverage/gap checks are publish-path over the indexed owned `pricing_price_window` table; the activation job scans by `(state, effective_from)` index in batches; the sellability surface is a pinned read-model lookup (order-time hot path — inside the read p95 < 100ms budget). +- **Observability / metrics**: `pricing_window_coverage_blocks_total`, `pricing_window_gap_blocks_total`, `pricing_window_activation_lag_seconds` (job SLO), `pricing_windows{state}` gauge, `pricing_grandfathered_rows{state}` gauge. +- **Security & AuthZ**: cutovers and `grandfatherUntil` changes are material (Slice 5 two-person rule); window scheduling/cancellation/adjustment is `plan × write` through the shared PEP gate (Slice 5 catalog — same authority as price authoring; the window is an attribute of the row's sellable life). +- **Risks & open items**: the consolidation decision (D-03; PRD §15 answered) needs the formal Architecture ack — the legacy UC doc is banner-marked as absorbed; its FX rate-lock and subscription-impact-preview scenarios are dispositioned out (§1.5); the activation job's SLO value rides the provisional-NFR ratification (PRD §14). diff --git a/gears/bss/pricing/docs/design/08-bundles.md b/gears/bss/pricing/docs/design/08-bundles.md new file mode 100644 index 000000000..94a5bf9ba --- /dev/null +++ b/gears/bss/pricing/docs/design/08-bundles.md @@ -0,0 +1,259 @@ + + + +# DESIGN — Bundles & Marketplace Composition (Slice 8) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author and Publish a Bundle](#author-and-publish-a-bundle) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Price-Basis Validation](#price-basis-validation) + - [Component Coverage Validation](#component-coverage-validation) + - [Rev-Share Reconciliation](#rev-share-reconciliation) +- [4. States (CDSL)](#4-states-cdsl) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Bundle Composition](#bundle-composition) + - [Rev-Share](#rev-share) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns **bundle composition**: the price basis (`sum_of_parts` referencing component +**`planId`s** vs `own_price`), component publication + per-`(currency, region)` coverage +validation (reusing Slice 4's `CurrencyBindingChecker`), **rev-share reconciliation** +(sum-to-100% per included vendor SKU, explicit platform cut, bundle-level residual absorber +— default the platform — with ±1 bp authoring tolerance normalized to an exact split at +publish, D-07), and `invoiceItemization` (`aggregate | itemize`) preserving per-SKU +rev-share for Marketplace accrual. The `bundle` SKU **type** is registry-owned; this slice +authors what the bundle *contains and how it prices*. + +**Traces to**: `cpt-cf-bss-pricing-fr-bundle-composition`, +`cpt-cf-bss-pricing-fr-invoice-currency-binding` + +### 1.2 Purpose + +Make marketplace bundles unambiguous and reconcilable: a `sum_of_parts` bundle can never sum +rows that don't exist or mix frequencies; a rev-share split always reconciles to 100% with a +deterministic owner of the rounding residual; itemization choice never loses the per-SKU +split Marketplace accrues fees from. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-product-manager` | Authors bundles, constraints, rev-share | +| `cpt-cf-bss-pricing-actor-marketplace` | Consumes rev-share rules for fee accrual | +| `cpt-cf-bss-pricing-actor-catalog-registry` | Owns the `bundle` SKU type + published `includedSkuIds` | +| `cpt-cf-bss-pricing-actor-billing` | Consumes `invoiceItemization`; single-currency invariant | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.3 (bundle composition), §17.3 (bundle rules), §5.1 +- **Design**: [01-foundation.md](./01-foundation.md); [04-currency-tax.md](./04-currency-tax.md) — `CurrencyBindingChecker` (cases ii/iii) +- **Dependencies**: Slices 1–4 (published component plans/rows to reference). + +### 1.5 Scope + +**In scope**: bundle price basis + persistence; component `planId` referencing (not bare +SKUs) for `sum_of_parts`; coverage validation per sold `(currency, region)` + matching +`frequency`; rev-share model + residual tolerance; `invoiceItemization` persistence. + +**Out of scope**: the `bundle` SKU type/flag (registry); marketplace fee **accrual** +(Marketplace); the summed **amount** at quote time (Tariffs — the catalog persists the +basis, not the sum); listing eligibility rules (Future, §17.8). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-8-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| B1 | Component identity | `sum_of_parts` references component **`planId`s** — bare `skuId`s are ambiguous per `(currency, region)` | PRD §1.4 | +| B2 | Rev-share residual | Sum = 100% per included vendor SKU; authoring tolerance **±1 bp** (= 0.01%); publish **normalizes** the nominated absorber's effective share to an exact 10000 bp split (typed values audited); absorber default = the **platform** (D-07) | PRD §6.3; D-07 | +| B3 | Itemization | `aggregate \| itemize` persisted; either preserves per-SKU rev-share for accrual | PRD §6.3 | + +### 1.7 Naming & Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `BundleValidator` | Registered rules: basis, component publication, coverage (via `CurrencyBindingChecker`), frequency match | +| `RevShareReconciler` | The 100%-per-vendor-SKU check + residual **normalization** onto the bundle-level absorber (D-07) | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + REG["Catalog registry
bundle SKU type · published includedSkuIds"] + subgraph s8["Slice 8 — Bundles"] + BV["BundleValidator"] + RR["RevShareReconciler"] + end + CBC["CurrencyBindingChecker (Slice 4)"] + FND["Foundation (Slice 1)"] + MKT["Marketplace (fee accrual)"] + REG --> s8 + BV --> CBC + BV --> FND + RR --> FND + FND --> MKT +``` + +## 2. Actor Flows (CDSL) + +### Author and Publish a Bundle + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-flow-bundle-author` + +**Actor**: `cpt-cf-bss-pricing-actor-product-manager` (authoring — `bundle × write`); the publish step requires **`plan × publish` only** (D-11) — held by `cpt-cf-bss-pricing-actor-finance-manager` / `cpt-cf-bss-pricing-actor-catalog-admin` (Slice 5 role matrix); the composition is protected by the approval content pin, and component checks at publish are validations, not caller authz + +**Success Scenarios**: +- A bundle (on a registry `bundle`-type SKU) declares its basis, published `includedSkuIds`, component `planId`s (`sum_of_parts`), rev-share, and `invoiceItemization`; publish validates coverage + reconciliation; `BundleUpdated` emits + +**Error Scenarios**: +- Unpublished included SKU / component plan → 422; component row missing for a sold `(currency, region)` or mismatched `frequency` → `CURRENCY_NOT_COVERED` / `FREQUENCY_MISMATCH` (422); rev-share off by more than 1 bp per vendor SKU → `RESIDUAL_OVER_TOLERANCE` (422); structurally missing platform cut / malformed shares → `REVSHARE_UNBALANCED` (422) + +**Steps**: +1. [ ] - `p2` - API: POST/PATCH /v1/pricing/bundles (draft; idempotency key) - `inst-ba-author` +2. [ ] - `p2` - Publish: `BundleValidator` + `RevShareReconciler` run in the Foundation pipeline - `inst-ba-validate` +3. [ ] - `p2` - **RETURN** 202; `BundleUpdated` emitted; composition frozen into the read model / snapshot - `inst-ba-return` + +## 3. Processes / Business Logic (CDSL) + +### Price-Basis Validation + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-bundle-basis` + +**Steps**: +1. [ ] - `p2` - Basis MUST be declared: `sum_of_parts` or `own_price`; the basis and any explicit price persist and freeze - `inst-bb-declared` +2. [ ] - `p2` - `sum_of_parts`: component **`planId`s** referenced (B1), each published; a component `planId` MUST NOT itself be a `bundle`-type plan — flat composition at launch, nesting is Future scope (`COMPONENT_IS_BUNDLE`; re-composition is re-validated, so a cycle can never form); the summing itself is Tariffs' — the catalog persists the reference set only - `inst-bb-sum` +3. [ ] - `p2` - `own_price`: the bundle's own price rows live on the canonical scope key like any plan's (Slices 3/4 rules apply); a matching-currency component set is still required (Slice 4 case iii) - `inst-bb-own` + +### Component Coverage Validation + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-bundle-coverage` + +**Steps**: +1. [ ] - `p2` - Every referenced component MUST have a covering **published** row in each `(currency, region)` the bundle sells in — the currency axis delegates to `CurrencyBindingChecker` case ii; the `region` axis is the BundleValidator's own extension of the same rule - `inst-bc-coverage` +2. [ ] - `p2` - Recurring components MUST match `frequency` (a monthly + annual mix cannot sum onto one invoice line set) - `inst-bc-frequency` +3. [ ] - `p2` - A missing or ambiguous component row fails publish naming the component + `(currency, region)` - `inst-bc-fail` +4. [ ] - `p1` - **Bundle sellability (normative):** the Slice 7 gate evaluates a bundle as the **conjunction** over its components — sellable at `t` iff **every** referenced component key passes all gate predicates at `t` (plus the bundle's own `availableFrom`/`availableTo`). For `sum_of_parts` there are no own rows, so components are the only inputs; for `own_price` the bundle's **own** rows must pass **and** the component keys too (the matching-currency component set is part of the offer). One unsellable component makes the bundle unsellable, never partially-sellable - `inst-bc-sellability` + +### Rev-Share Reconciliation + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-revshare` + +**Steps**: +1. [ ] - `p2` - When set, rev-share MUST sum to **100% per included vendor SKU**, with an explicit platform cut - `inst-rs-sum` +2. [ ] - `p2` - **Residual normalization (B2, D-07):** authoring accepts `|Σ(share_bp) + platform_cut_bp − 10000| ≤ 1 bp`; at publish the bundle-level **`residual_absorber`** (a vendor SKU or the **platform** — default platform, so an "unnominated" state cannot exist) has its **effective** share adjusted by the residual, and the read model publishes effective shares summing to **exactly 10000 bp** (typed values retained for audit, the adjustment recorded). A residual over 1 bp fails publish (`RESIDUAL_OVER_TOLERANCE`) — e.g. a six-way even split (6 × 1666 bp = 9996) must be reconciled by the operator. Monetary (cent-level) rounding at settlement is a separate downstream rule and also lands on the absorber - `inst-rs-residual` +3. [ ] - `p2` - `invoiceItemization` (`aggregate | itemize`) persists and MUST preserve per-SKU rev-share either way (Marketplace accrues per SKU regardless of invoice layout) - `inst-rs-itemization` + +## 4. States (CDSL) + +No slice-owned state machine: bundles ride the plan lifecycle (draft → published → retired) +of Slices 2/11 on their `bundle`-type SKU. + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST/PATCH` | `/v1/pricing/bundles` | Author bundle composition (draft) | idempotency key / ETag | +| `POST` | `/v1/pricing/bundles/{bundleId}/publish` | Validate + publish | per revision | + +**Problem responses (RFC 9457):** `BASIS_MISSING` (422), `COMPONENT_UNPUBLISHED` (422), +`COMPONENT_IS_BUNDLE` (422 — flat composition at launch; nesting is Future), +`CURRENCY_NOT_COVERED` (422), `FREQUENCY_MISMATCH` (422), `REVSHARE_UNBALANCED` (422 — +structurally malformed shares / missing explicit platform cut), `RESIDUAL_OVER_TOLERANCE` +(422 — `|Σ − 10000| > 1 bp`; D-07). + +## 6. Data Model + +Slice-owned tables (`pricing_` prefix per Foundation §3.7): + +**`pricing_bundle`** (PK `bundle_id`; FK to the registry `bundle`-type SKU): `price_basis` +(`sum_of_parts | own_price`), `invoice_itemization` (`aggregate | itemize`), lifecycle refs. + +**`pricing_bundle_component`** (FK `bundle_id`): `included_sku_id`, `component_plan_id` +(required for `sum_of_parts`, B1), constraints (min/max qty). + +**`pricing_bundle_revshare`** (FK `bundle_id`): `vendor_sku_id`, `party`, `share_bp` (typed, +basis points), `effective_share_bp` (published; absorber-adjusted at publish), +`platform_cut_bp`. The absorber lives on the bundle: **`pricing_bundle.residual_absorber`** +(`platform` sentinel or a `vendor_sku_id`; default `platform` — D-07). + +Key constraints: authoring accepts `|SUM(share_bp) + platform_cut_bp − 10000| ≤ 1 bp` per +`(bundle_id, vendor_sku_id)`; publish normalizes onto the absorber so +`SUM(effective_share_bp) + platform_cut_bp = 10000` **exactly** (D-07); a residual over 1 bp +fails (`RESIDUAL_OVER_TOLERANCE`). Downstream consumers read only the effective shares. + +## 7. Events & Alarms + +`BundleUpdated` (frozen set) on composition change. No slice alarms — validation failures are +synchronous; accrual mismatches are Marketplace-side reconciliation. + +## 8. Definitions of Done + +### Bundle Composition + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-bundle-composition` + +A bundle **MUST** declare its basis, reference published SKUs and (for `sum_of_parts`) +component `planId`s covering every sold `(currency, region)` with matching `frequency`; +a missing/ambiguous component fails publish naming it. + +**Implements**: `cpt-cf-bss-pricing-flow-bundle-author`, `cpt-cf-bss-pricing-algo-bundle-basis`, `cpt-cf-bss-pricing-algo-bundle-coverage` + +**Touches**: +- API: `POST/PATCH /v1/pricing/bundles` +- DB: `pricing_bundle`, `pricing_bundle_component` +- Entities: `BundleValidator` + +### Rev-Share + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-revshare` + +Rev-share **MUST** sum to 100% per vendor SKU with an explicit platform cut; authoring +accepts a residual of ≤ 1 bp, which publish **normalizes** onto the bundle-level absorber +(default the platform) so published **effective shares sum to exactly 10000 bp** — typed +values audited, over-tolerance rejected (D-07); `invoiceItemization` **MUST** preserve +per-SKU rev-share for Marketplace accrual under either layout. + +**Implements**: `cpt-cf-bss-pricing-algo-revshare` + +**Touches**: +- DB: `pricing_bundle_revshare` +- Entities: `RevShareReconciler` + +## 9. Acceptance Criteria + +Unit: + +- [ ] Basis matrix (`sum_of_parts` without component planIds fails; `own_price` without matching-currency components fails); a component `planId` that is itself a `bundle`-type plan fails (`COMPONENT_IS_BUNDLE`); a 33.33%×3 split (9999 bp) publishes with the platform absorber's effective share normalized to an exact 10000 (adjustment recorded); a residual over 1 bp fails (`RESIDUAL_OVER_TOLERANCE`); frequency mismatch fails + +Integration (testcontainers): + +- [ ] A two-vendor `sum_of_parts` bundle over two currencies publishes only when every component covers both; dropping one component row blocks with the component + currency named +- [ ] `itemize` and `aggregate` both project per-SKU rev-share into the read model + +## 10. Non-Functional Considerations + +- **Performance**: coverage validation is O(components × currencies) at publish; read model exposes the frozen composition flat. +- **Observability**: `pricing_bundle_validation_failures_total{rule}`. +- **Risks & open items**: upstream SKU retirement while a bundle references it — joint remediation contract open with the registry (PRD §15); marketplace listing-eligibility rules deferred (§17.8). **Component-retirement guard**: retiring a plan referenced as a bundle component is blocked/reported by Slice 11 (`inst-re-references`) until the bundle is remediated. diff --git a/gears/bss/pricing/docs/design/09-price-overlays.md b/gears/bss/pricing/docs/design/09-price-overlays.md new file mode 100644 index 000000000..898ce8815 --- /dev/null +++ b/gears/bss/pricing/docs/design/09-price-overlays.md @@ -0,0 +1,361 @@ + + + +# DESIGN — Price Overlays & Customer-Group Segment Pricing (Slice 9) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author a PriceOverlay](#author-a-priceoverlay) + - [Manage Customer-Group Membership](#manage-customer-group-membership) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [PriceOverlay Authoring Validation](#priceoverlay-authoring-validation) + - [Customer-Group Taxonomy and Membership](#customer-group-taxonomy-and-membership) + - [Membership-Change Materiality](#membership-change-materiality) +- [4. States (CDSL)](#4-states-cdsl) + - [Membership Record State Machine](#membership-record-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [PriceOverlay Authoring](#priceoverlay-authoring) + - [Customer-Group Pricing](#customer-group-pricing) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns **`PriceOverlay` authoring and validation** — scope +(partner/orgTier/brand/region/customerGroup/global), adjustment (`markup | discount | +fixed`), **explicit precedence** (duplicate precedence within one scope class rejected), own +`[effectiveFrom, effectiveTo)` dating, declared tax basis — and the **customer-group segment +pricing** capability: the BSS-owned **group taxonomy**, the **effective-dated, audited +membership** record on the payer's commercial profile (resolved via `payerTenantId`), and the +freezing of the **resolved group** into `pricingSnapshotRef`. Precedence/stacking +**evaluation** is Tariffs'; base price rows always stay `priceOverlay = base` (Foundation §4.1). + +**Traces to**: `cpt-cf-bss-pricing-fr-priceoverlay-authoring`, +`cpt-cf-bss-pricing-fr-priceoverlay-referential-integrity`, +`cpt-cf-bss-pricing-fr-customer-group-pricing` + +### 1.2 Purpose + +Give commercial teams governed overlays — partner, brand, and segment pricing — without +cloning SKUs or adding price-row axes: one base row set plus validated, precedence-ordered +adjustment overlays. Customer groups are a **BSS commercial projection** (trial/beta/VIP/…) +that never touches tenant topology, with membership as an auditable, effective-dated, +snapshot-frozen record so a payer's segment price is always reproducible. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-catalog-admin` | Authors PriceOverlays, the group taxonomy, memberships | +| `cpt-cf-bss-pricing-actor-rating` | Evaluates overlays (precedence/stacking); resolves membership at `t` | +| `cpt-cf-bss-pricing-actor-finance-reviewer` | Approves membership changes / group moves (material) | +| `cpt-cf-bss-pricing-actor-auditor` | Reads membership audit history | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.6, §17.7 (customer-group detail), §1.4 (Glossary: `brand`, `customerGroup`) +- **Design**: [01-foundation.md](./01-foundation.md) — scope key (`priceOverlay = base` on rows); [04-currency-tax.md](./04-currency-tax.md) — brand taxonomy rule; [05-governance.md](./05-governance.md) — materiality registration +- **Dependencies**: Slices 1, 4, 5. Consumed by Tariffs (evaluation) and frozen by the Foundation snapshot. + +### 1.5 Scope + +**In scope**: `PriceOverlay` CRUD + validation (scope, adjustment, precedence uniqueness per +scope class, effective dating, tax-basis declaration, referential integrity to published +targets); the customer-group taxonomy (BSS-governed); effective-dated audited membership on +`payerTenantId`; resolved-group snapshot freezing; membership-change governance +(renewal-aligned default; immediate = explicit material change). + +**Out of scope**: precedence/stacking **evaluation** and overlay math (Tariffs); contract +overrides (Contracts); per-group **different tier structures** (Future — separate plans); +AMS/tenant topology (membership is a BSS projection, never a tenant attribute). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-9-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| L1 | Overlay, not axis | `PriceOverlay` rows are overlays evaluated by Tariffs; price rows authored in this gear always carry `priceOverlay = base`; publish-time coverage resolves on base | PRD §2.2 | +| L2 | Precedence explicit | Integer `precedence`, unique within one scope class; ties are authoring errors, not runtime resolution | PRD §6.6 | +| L3 | Membership dating | Membership is effective-dated `[from, to)`; the group resolved at `t` via `payerTenantId`; renewal-aligned re-resolution by default | PRD §17.7 | +| L4 | Materiality | A group discount/move affecting many payers and any **immediate** re-resolution are material changes (registered into Slice 5's evaluator) | PRD §17.7 | +| L5 | Tax basis | A `PriceOverlay` MUST declare its tax basis or explicitly delegate to Tariffs — silence fails publish | PRD §6.6 | +| L6 | Overlay disclosure | Every `PriceOverlay` carries `disclosure ∈ {restricted (default), public}`. `restricted` = the overlay (including its **existence**) is never exposed on any consumer-facing enumeration or preview and resolves only inside the member payer's own evaluation context; `public` = Presentation/Tariffs preview MAY disclose the adjusted price to anyone. Fail-closed default. *(Backfilled into PRD §6.6 / fr-priceoverlay-authoring.)* | Design (this slice) | +| L7 | Membership scope | At launch, membership resolves for the **direct** `payerTenantId` only. **Needs-decision:** subtree inheritance — a membership on a parent payer covering its payer-subtenants (proposal: resolve by walking the payer hierarchy upward, most-specific/nearest membership wins; record + freeze the `inherited_via` chain in the snapshot for auditability; a parent-membership change then cascades materiality over the subtree). Without it, "the whole client's structure gets the discount" requires enrolling every payer-subtenant individually. Owner: Product + Architecture. | Design (this slice) | + +### 1.7 Naming & Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `PriceOverlayValidator` | Registered rules: scope validity (incl. brand via Slice 4's taxonomy rule), adjustment shape, precedence uniqueness, effective-interval sanity, tax-basis declaration, referential integrity | +| `GroupTaxonomy` | The BSS-governed customer-group value set (like region/brand) | +| `MembershipLedger` | The effective-dated, audited membership records per payer commercial profile | +| `ResolvedGroupFreezer` | The **joint contract name** (D-30), not a catalog runtime component: the catalog publishes membership into the read model; **Tariffs** performs the interval resolution at activation/renewal and freezes the resolved group into the `pricingSnapshotRef` **it composes** (composition SoR) | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s9["Slice 9 — Price Overlays & Segments"] + PLV["PriceOverlayValidator"] + GT["GroupTaxonomy"] + ML["MembershipLedger"] + RGF["ResolvedGroupFreezer"] + end + BT["Brand taxonomy (Slice 4)"] + GOV["MaterialityEvaluator (Slice 5)"] + FND["Foundation (Slice 1)
read model · pricingSnapshotRef"] + TRF["Tariffs
overlay evaluation · membership resolution at t"] + PLV --> BT + ML --> GOV + PLV --> FND + RGF --> FND + FND --> TRF +``` + +## 2. Actor Flows (CDSL) + +### Author a PriceOverlay + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-priceoverlay-author` + +**Actor**: `cpt-cf-bss-pricing-actor-catalog-admin` + +**Success Scenarios**: +- A `PriceOverlay` with scope, adjustment, unique precedence, optional effective interval, and declared tax basis validates and lands in the read model for Tariffs evaluation + +**Error Scenarios**: +- Duplicate `precedence` within the scope class → `PRECEDENCE_DUPLICATE` (409) +- Scope referencing an unpublished plan/SKU → `TARGET_UNPUBLISHED` (422, not exposed in the read model) +- Unknown `brand`/`customerGroup` value → taxonomy failure (422) +- Missing tax basis (no declaration, no explicit delegation) → `TAX_BASIS_UNDECLARED` (422) + +**Steps**: +1. [ ] - `p1` - API: POST/PATCH /v1/pricing/price-overlays (idempotency key / ETag) - `inst-pl-author` +2. [ ] - `p1` - `PriceOverlayValidator` runs the L2/L5 + referential + taxonomy rules - `inst-pl-validate` +3. [ ] - `p1` - **RETURN** 201/200; the committed overlay is a **publish unit through the Foundation engine** (D-06): validation → pending `CatalogVersion` ref → read-model warm — consumer-visible only at `CatalogVersionPublished` + warm-completion, the same monotonic pinning as plan content (evaluation downstream) - `inst-pl-return` + +### Manage Customer-Group Membership + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-group-membership` + +**Actor**: `cpt-cf-bss-pricing-actor-catalog-admin` (approval per L4 where material) + +**Success Scenarios**: +- A payer's membership record (`payerTenantId`, group, `[from, to)`) is created/ended; audited; renewal-aligned by default — the subscription's pinned snapshot keeps the old group until renewal +- An **immediate** re-resolution is executed as an explicit material change (Slice 5 approval) + +**Error Scenarios**: +- Unknown group value → taxonomy failure (422); overlapping membership intervals for one payer in one group → `MEMBERSHIP_OVERLAP` (409) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/customer-groups/{group}/members (payer, effective interval) - `inst-gm-api` +2. [ ] - `p1` - `MembershipLedger` validates interval non-overlap per `(payer, group)`; every change audited (actor, before/after, reason) - `inst-gm-ledger` +3. [ ] - `p1` - Material paths (L4) route through Slice 5 approval before commit - `inst-gm-material` +4. [ ] - `p1` - **RETURN** 201; the committed membership mutation is a **publish unit through the Foundation engine** (D-06 — pending ref → warm; registry batching coalesces bulk enrollments), so a renewal after the commit always sees it; **Tariffs** resolves the group at `t` and freezes it into the snapshot it composes (`ResolvedGroupFreezer` = the joint contract, D-30 — the catalog has no per-subscription snapshot participation and no resolve-for-payer endpoint) - `inst-gm-return` + +## 3. Processes / Business Logic (CDSL) + +### PriceOverlay Authoring Validation + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-priceoverlay-validate` + +**Steps**: +1. [ ] - `p1` - Scope ∈ {partner, orgTier, brand, region, customerGroup, global}; scope values validated against their taxonomies (brand → Slice 4; customerGroup → `GroupTaxonomy`) - `inst-plv-scope` +2. [ ] - `p1` - Adjustment ∈ {markup, discount, fixed}. A **percent** magnitude is a single basis-points value (currency-neutral). An **amount-based** magnitude (`fixed`, absolute markup/discount) is money and therefore exists only **per currency** (D-08, no-implicit-FX): the adjustment carries a `pricing_price_overlay_amount` value set that MUST cover **every currency the overlay's target scope sells** — each value validated at its currency's ISO 4217 minor unit; a missing currency fails save/publish (`ADJUSTMENT_CURRENCY_NOT_COVERED`). A base row in a **new** currency published later flags affected amount-based overlays `coverage_incomplete` (+ alarm; the uncovered market resolves without the overlay — normal precedence semantics — until the operator adds the value) - `inst-plv-adjustment` — *D-42 (PROPOSED 2026-07-13, flagged for veto): a `PriceOverlay` MAY instead hold **per-plan adjustment lines** keyed `(planId, targetSku?)`, each with its own kind + magnitude (most-specific-wins within a list). Prototyped in Pricing Studio; the per-currency coverage rule above then re-attaches **per line**. Not normative until Product/Finance rule.* +3. [ ] - `p1` - `precedence` unique within one scope class (L2); duplicate rejected at save - `inst-plv-precedence` +3a. [ ] - `p1` - **Cross-class tie-break (joint contract):** `precedence` is unique only within a class, so overlays from **different** classes can tie. The read model publishes the normative **class-specificity order** — `customerGroup > partner > orgTier > brand > region > global` — as the tie-break Tariffs MUST adopt verbatim (the most-specific-wins doctrine, mirroring grandfathering eligibility); authoring additionally **warns** on an equal-precedence cross-class pair with overlapping targets so the operator sees the tie before relying on the break - `inst-plv-class-tiebreak` +4. [ ] - `p1` - Optional `[effectiveFrom, effectiveTo)` validated per scope + adjustment target (its own interval — **not** on the canonical price-row key; overlays are not price rows); overlapping intervals for one `(scope, target)` pair are rejected at authoring (`OVERLAY_INTERVAL_OVERLAP`, 409) — the overlay analogue of window non-overlap - `inst-plv-dating` +5. [ ] - `p1` - Tax basis declared or explicitly delegated to Tariffs (L5); silence fails - `inst-plv-taxbasis` +6. [ ] - `p1` - Referential integrity: a scope referencing an unpublished plan/SKU is rejected and never exposed in the read model. **Retirement of a targeted plan does not block on overlays** (D-31): the overlay goes **dangling-and-flagged** — read-model flag + `pricing.priceoverlay.target_retired` (Warn); in-flight subscribers legitimately keep resolving retired plans' rows, so the overlay stays evaluable for them; remediation = end or retarget the overlay - `inst-plv-referential` +7. [ ] - `p1` - **Disclosure (L6):** `disclosure` defaults to `restricted` — the overlay is excluded from every consumer-facing enumeration and from the base-price preview (Slice 4 returns base + disclaimer only, regardless), and materializes only in the member payer's own Tariffs evaluation/quote/invoice; `public` overlays MAY be disclosed by Presentation / the Tariffs effective-price preview (F-34). Operator/service reads (`price_overlay × read`) are unaffected — the flag governs **consumer-facing** exposure only - `inst-plv-disclosure` +8. [ ] - `p1` - **Member-scoped storefront rendering (joint contract):** "each payer sees their own price" is delivered by the **Tariffs effective-price evaluation in the caller's payer context**, where the payer identity **MUST derive from the authenticated caller's claims (gateway), never from a client-supplied `payerTenantId` parameter** — otherwise a non-member could query another payer's restricted price. The catalog contributes base rows + overlay definitions + membership; checkout submits `planId` only (no client-supplied price), and the resolved group frozen in the snapshot is what rating charges. The Tariffs member-scoped preview (F-34) is required **before restricted segment pricing sells self-service** — a tracked **GA gate** on F-34 (owner: Tariffs + GTM; program board per PRD §13, D-33) — the slice itself does not hold on it - `inst-plv-member-preview` + +### Customer-Group Taxonomy and Membership + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-group-membership` + +**Steps**: +1. [ ] - `p1` - `GroupTaxonomy` is BSS-owned and governed like region/brand (values validated at authoring; retire guarded by referential checks) - `inst-cg-taxonomy` +2. [ ] - `p1` - Membership is an **effective-dated, audited BSS record** on the payer's commercial profile keyed by `payerTenantId` — AMS supplies identity only; tenant topology is never modified - `inst-cg-record` +3. [ ] - `p1` - Resolution: the group at `t` = the membership interval covering `t` — **at most one active membership per payer across all groups** (D-09): a second enrollment while one is active is rejected (`MEMBERSHIP_CONFLICT`, names the active one), so the resolved group is unique **by construction**; a transfer is the atomic **move** operation (end current + start new in one audited mutation; renewal-aligned by default, immediate = material per `inst-mm-*`); Tariffs applies the `customerGroup` `PriceOverlay` per group × region - `inst-cg-resolve` +4. [ ] - `p1` - **Determinism:** the resolved group freezes into `pricingSnapshotRef`; a pinned subscription keeps its frozen group until renewal re-resolution (L3) - `inst-cg-freeze` +5. [ ] - `p2` - **Membership scope (L7):** resolution matches the **direct** `payerTenantId` at launch; subtree inheritance (parent membership covering payer-subtenants via a payer-hierarchy walk, nearest-wins, `inherited_via` frozen in the snapshot) is a recorded **needs-decision** — until decided, per-subtenant enrollment is the supported path (automatable off AMS subtenant-creation events as an ops concern) - `inst-cg-subtree` +6. [ ] - `p1` - **Segment-pricing routing rule (normative):** a segment needing a **price adjustment** (±%, fixed) on the base structure → `customerGroup` overlay (this slice; server-side resolution, no leakable id); a segment needing a **different structure** (other tiers/counts/mechanics) → a **separate plan**, operator-channel only until group-scoped plan eligibility lands (Slice 7 `inst-sg-eligibility-gated`); **negotiated per-account terms** → Contracts (out of catalog). Free-for-internal groups → a separate `$0`-row plan (Slice 3 Q5) - `inst-cg-routing` +7. [ ] - `p2` - **Eligibility-gate policy set (needs-decision, gates F-88 activation):** (a) membership ends while a subscription lives — default proposal: the subscription continues to renewal, then re-binds/migrates (mirrors `grandfatherUntil` semantics); (b) operator sale outside the group — explicit audited override vs enroll-first (proposal: enroll-first, no override); (c) interaction with `allowedChangeTargets` — a self-service change INTO an eligibility-gated plan requires **both** the change edge and membership. Owner: Product + Finance; MUST be decided before `eligibleCustomerGroups` activates - `inst-cg-eligibility-policy` + +### Membership-Change Materiality + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-membership-materiality` + +**Steps**: +1. [ ] - `p1` - Default: a membership change is **renewal-aligned** — no approval needed beyond audit; the price effect lands at each subscription's next renewal re-resolution - `inst-mm-renewal` +2. [ ] - `p1` - **Immediate** re-resolution is an explicit **material change** → Slice 5 two-person rule - `inst-mm-immediate` +3. [ ] - `p1` - A group discount change or group move affecting many payers is **material** (registered as an always-material trigger in Slice 5's evaluator) - `inst-mm-bulk` +4. [ ] - `p1` - All membership changes audited (Slice 5 `AuditTrail`) - `inst-mm-audit` + +## 4. States (CDSL) + +### Membership Record State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-state-membership` + +**States**: scheduled (`from > now`), active (`from ≤ now < to`), ended (`to ≤ now`) +**Initial State**: scheduled or active per the authored interval + +**Transitions**: +1. [ ] - `p1` - Time-driven (`from`/`to` passing); records are never mutated in place — ending early = setting `to` (audited); history retained - `inst-ms-time` +1a. [ ] - `p1` - **Move (D-09):** transferring a payer between groups is one atomic audited mutation — end the active membership + create the new one at the same instant (no gap, no overlap); it follows the standard materiality rules (renewal-aligned default; immediate = material) - `inst-ms-move` +2. [ ] - `p2` - A pinned subscription's **frozen** group is unaffected by transitions until renewal (L3) - `inst-ms-pinned` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | +|--------|------|---------|-------------| +| `POST/PATCH` | `/v1/pricing/price-overlays` | Author/validate an overlay | idempotency key / ETag | +| `GET` | `/v1/pricing/price-overlays` | List overlays (admin/Tariffs read) | — | +| `GET/PUT` | `/v1/pricing/customer-groups/taxonomy` | The BSS group taxonomy | ETag | +| `POST` | `/v1/pricing/customer-groups/{group}/members` | Create an effective-dated membership | idempotency key | +| `PATCH` | `/v1/pricing/customer-groups/{group}/members/{id}` | End/adjust an interval (audited) | ETag | +| `POST` | `/v1/pricing/customer-groups/{group}/members/{payerId}/move` | Atomic transfer from the payer's active group (end + start, one audited mutation; D-09) | idempotency key | + +**Problem responses (RFC 9457):** `PRECEDENCE_DUPLICATE` (409), `OVERLAY_INTERVAL_OVERLAP` +(409 — overlapping effective intervals for one `(scope, target)` pair), `TARGET_UNPUBLISHED` +(422), `TAX_BASIS_UNDECLARED` (422), `ADJUSTMENT_CURRENCY_NOT_COVERED` (422 — an +amount-based adjustment missing a value for a currency its target scope sells, D-08), +`GROUP_UNKNOWN` (422), `MEMBERSHIP_OVERLAP` (409 — overlapping intervals within one group), +`MEMBERSHIP_CONFLICT` (409 — the payer already holds an active membership in another group; +use the move operation — D-09). + +## 6. Data Model + +Slice-owned tables (`pricing_` prefix per Foundation §3.7): + +**`pricing_price_overlay`** (PK `price_overlay_id`): + +| Column | Type | Notes | +|--------|------|-------| +| `scope_class` | `enum` | `partner \| org_tier \| brand \| region \| customer_group \| global` | +| `scope_value` | `string` | taxonomy-validated per class | +| `adjustment_kind` | `enum` | `markup \| discount \| fixed` | +| `adjustment_value` | `int` | **basis points; percent adjustments only** — amount-based magnitudes live in `pricing_price_overlay_amount` (D-08) | +| `precedence` | `int` | `UNIQUE (tenant_id, scope_class, precedence)` (L2) | +| `effective_from` / `effective_to` | `timestamptz` | optional overlay dating (own interval) | +| `tax_basis` | `enum` | `inclusive \| exclusive \| delegated_tariffs`; NOT NULL (L5) | +| `disclosure` | `enum` | `restricted (default) \| public` — consumer-facing exposure of the overlay (L6); NOT NULL | +| `target_ref` | `jsonb` | plan/SKU targets; referential-validated | + +**`pricing_price_overlay_amount`** (FK `price_overlay_id`; amount-based adjustments only, D-08): +`currency` (ISO 4217), `value_minor` (`bigint`, validated at the currency's minor unit); +`UNIQUE (price_overlay_id, currency)`. The value set MUST cover every currency the overlay's +target scope sells (`ADJUSTMENT_CURRENCY_NOT_COVERED` otherwise); rows exist only when +`adjustment_kind` is amount-based. + +**`pricing_customer_group_taxonomy`** — like region/brand (Slice 4 pattern): `(tenant_id, +value)`, `state`, retire guarded. + +**`pricing_group_membership`** (PK `membership_id`): + +| Column | Type | Notes | +|--------|------|-------| +| `payer_tenant_id` | `uuid` | the payer's commercial profile key | +| `group_value` | `string` | FK-like to the taxonomy | +| `effective_from` / `effective_to` | `timestamptz` | `[from, to)`; non-overlap per `(payer, group)` **and at most one active membership per `payer_tenant_id` across all groups** (D-09) enforced at write | +| `reason` / `actor` | — | audit surface (full trail in `pricing_audit_log`) | + +## 7. Events & Alarms + +No new frozen event names — every committed overlay/membership mutation is its **own publish +unit through the Foundation engine** (D-06): it requests `CatalogVersion` addressability and +warms the read model without a dedicated event (consumers observe `CatalogVersionPublished` ++ the warmed content; the registry's batching coalesces chatty membership traffic; the +increment-trigger taxonomy line rides the open §15 Registry item). Membership changes are +audited mutations. Alarms: +`pricing.segment.membership_renewal_backlog` (Info — memberships ended/changed whose +subscriptions have not yet re-resolved at renewal; expected steady-state visibility), +`pricing.priceoverlay.amount_coverage_incomplete` (Warn — a base row in a new currency +published after an amount-based overlay left it without a value for that market; the market +resolves without the overlay until remediated, D-08), +`pricing.priceoverlay.target_retired` (Warn — a published overlay targets a retired plan; +dangling-and-flagged, remediation = end/retarget the overlay, D-31 — mirrors the +`discountRef` pattern). + +## 8. Definitions of Done + +### PriceOverlay Authoring + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-priceoverlay` + +The catalog **MUST** author/validate `PriceOverlays`: scope + taxonomy-validated values, +adjustment shape, unique precedence per scope class, optional own-interval dating, declared +tax basis (or explicit delegation), referential integrity to published targets — with +evaluation staying in Tariffs and base rows staying `priceOverlay = base`. Every committed +overlay mutation **MUST** be its own publish unit through the Foundation engine +(version-pinned visibility, D-06). Amount-based adjustments carry **per-currency** values +covering every currency of the target scope (no implicit FX; fail-closed at authoring, +flag-and-remediate on later currency additions — D-08). + +**Implements**: `cpt-cf-bss-pricing-flow-priceoverlay-author`, `cpt-cf-bss-pricing-algo-priceoverlay-validate` + +**Touches**: +- API: `POST/PATCH /v1/pricing/price-overlays` +- DB: `pricing_price_overlay` +- Entities: `PriceOverlayValidator` + +### Customer-Group Pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-customer-group` + +The catalog **MUST** own the BSS customer-group taxonomy and the effective-dated, audited, +non-overlapping membership record on `payerTenantId`, freeze the resolved group in +`pricingSnapshotRef`, default membership changes to renewal-aligned, and route immediate +re-resolution and bulk group moves through the material-change policy; a payer holds **at +most one active membership across all groups** (conflicting enrollment rejected; transfer = +the atomic move — D-09). Every committed +membership mutation **MUST** be its own publish unit through the Foundation engine +(version-pinned visibility; registry batching coalesces bulk enrollments, D-06). + +**Implements**: `cpt-cf-bss-pricing-flow-group-membership`, `cpt-cf-bss-pricing-algo-group-membership`, `cpt-cf-bss-pricing-algo-membership-materiality`, `cpt-cf-bss-pricing-state-membership` + +**Touches**: +- API: `/v1/pricing/customer-groups/*` +- DB: `pricing_customer_group_taxonomy`, `pricing_group_membership` +- Entities: `GroupTaxonomy`, `MembershipLedger`, `ResolvedGroupFreezer` + +## 9. Acceptance Criteria + +Unit: + +- [ ] Precedence uniqueness per scope class (duplicate across classes allowed); overlapping effective intervals for one `(scope, target)` pair rejected (`OVERLAY_INTERVAL_OVERLAP`); tax-basis silence fails; an amount-based adjustment missing a currency of its target scope rejected (`ADJUSTMENT_CURRENCY_NOT_COVERED`) while a percent adjustment needs no currency values; per-currency amount precision follows the ISO 4217 minor unit; unpublished target rejected + hidden from the read model; membership interval overlap rejected; a cross-group concurrent enrollment rejected (`MEMBERSHIP_CONFLICT`) while the atomic move transfers with no gap/overlap instant; group-taxonomy retire guarded while referenced + +Integration (testcontainers): + +- [ ] A `customerGroup` PriceOverlay (group × region adjustment) publishes; a payer's membership resolves the group at `t`; the resolved group appears frozen in a new snapshot +- [ ] A membership change mid-subscription does **not** alter the pinned snapshot's group; renewal re-resolution picks the new group +- [ ] An immediate re-resolution requires approval (material); a bulk group discount routes material +- [ ] An overlay/membership committed while **no plan publishes** becomes rateable via its own publish unit at the next `CatalogVersionPublished` batch (within the propagation SLO); a renewal after that commit resolves the new membership — never "waits for an unrelated plan publish" +- [ ] Publishing a base row in a **new** currency flags targeting amount-based overlays `coverage_incomplete` (+ alarm); the new market resolves at base price until the operator adds the per-currency value + +## 10. Non-Functional Considerations + +- **Performance**: membership resolution is an indexed interval lookup per `(payer, group)` at snapshot/renewal time (not per rating call — the frozen group rides the snapshot). +- **Observability**: `pricing_priceoverlay_validation_failures_total{rule}`, `pricing_group_membership_changes_total{material}`. +- **Security & AuthZ**: taxonomy/membership mutation is admin-scoped, audited; material paths two-person (Slice 5). +- **Risks & open items**: per-group different tier structures deliberately Future (separate plans + group-scoped eligibility); this was a MAJOR launch-scope decision (2026-07-04, F-88) — the adjustment-only overlay is the committed shape. **D-42 (PROPOSED 2026-07-13, flagged for veto)** reopens only the *single-magnitude* part: a per-plan adjustment **line set** (one adjustment per `(planId, target)`, most-specific-wins within a list; class rank still stacks across lists) prototyped in Pricing Studio — still strictly adjustment-only (different *structures* stay Future). Not normative until Product/Finance rule. **Needs-decision (L7)**: membership subtree inheritance over the payer hierarchy (nearest-wins walk, `inherited_via` frozen; cascade materiality on parent-membership changes) vs per-subtenant enrollment — Owner: Product + Architecture. **L6 backfilled into the PRD** (§6.6); the Tariffs member-scoped effective-price preview (F-34) — where `restricted` overlays become visible to their own members — is REQUIRED for storefront UX (inst-plv-member-preview). diff --git a/gears/bss/pricing/docs/design/10-advanced-primitives.md b/gears/bss/pricing/docs/design/10-advanced-primitives.md new file mode 100644 index 000000000..28df20a2c --- /dev/null +++ b/gears/bss/pricing/docs/design/10-advanced-primitives.md @@ -0,0 +1,402 @@ + + + +# DESIGN — Advanced Pricing Primitives (Slice 10) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Author Advanced Primitives](#author-advanced-primitives) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Reserved-Capacity Attributes](#reserved-capacity-attributes) + - [Prepaid Credit Grant](#prepaid-credit-grant) + - [Derived (Composite) Meter](#derived-composite-meter) + - [Discount Reference Hook](#discount-reference-hook) + - [Minimum-Quantity Floor Typing](#minimum-quantity-floor-typing) + - [Trailing-Tier Qualification (Tier Rate-Lock)](#trailing-tier-qualification-tier-rate-lock) +- [4. States (CDSL)](#4-states-cdsl) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Reserved Capacity](#reserved-capacity) + - [Prepaid Grant](#prepaid-grant) + - [Derived Meter](#derived-meter) + - [Discount Hook & Floor Typing](#discount-hook--floor-typing) + - [Trailing-Tier Qualification](#trailing-tier-qualification) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the four advanced authoring primitives and one typing rule: +**reserved-capacity pricing** as attributes on the single usage row (`reservedRate` + +`reservationFlavor`, p1 — launch scope), the **prepaid credit grant** (definition only — +`category`, materialized usage-line `applicability`, drawdown-rank default (D-43); balance +execution GA-gated on Billing/Rating), the **derived (composite) meter** +(formula-as-**data** over ≥ 2 published units, one output unit), the **`discountRef` +day-1 hook** (referential integrity only), and the **typed `minQtyThreshold`** floor +(`purchase` vs `usage`). Every primitive freezes into `pricingSnapshotRef`; every piece of +math it implies is evaluated downstream. + +**Traces to**: `cpt-cf-bss-pricing-fr-reserved-capacity`, +`cpt-cf-bss-pricing-fr-prepaid-credit-grant`, `cpt-cf-bss-pricing-fr-derived-composite-meter`, +`cpt-cf-bss-pricing-fr-discount-ref-hook`, `cpt-cf-bss-pricing-fr-min-qty-floor` + +### 1.2 Purpose + +Cover the launch commercial shapes beyond plain rows — committed-rate IaaS selling, wallet +prepay, multi-unit composite pricing (VM = vCPU + RAM as one line), day-1 discounts — +without breaking the two structural rules that keep rating unambiguous: one priced line per +`(meter, dimensionKey)` (reservation = attributes, composite = one output unit) and zero +computation in the catalog. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Authors reserved rates, grants, floors | +| `cpt-cf-bss-pricing-actor-rating` | Evaluates reservation (step 6), composite formulas; sources from the snapshot | +| `cpt-cf-bss-pricing-actor-billing` | Owns prepaid balance/drawdown/auto-recharge execution (GA gate) | +| `cpt-cf-bss-pricing-actor-promotions` | Owns the external discount instrument `discountRef` resolves to | +| `cpt-cf-bss-pricing-actor-catalog-registry` | Declares the constituent `meteringUnit`s composites build on | +| `cpt-cf-bss-pricing-actor-contracts` | Owns negotiated RI-style rates (boundary) | +| `cpt-cf-bss-pricing-actor-subscriptions` | Enforces the `purchase`-type floor at order time | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.10, §17.7 (primitives detail), §17.2 (reservation fixture row), §17.4 (prepaid/floor validation rules) +- **Design**: [01-foundation.md](./01-foundation.md); [03-price-structure.md](./03-price-structure.md) — the usage row + `FixtureGate` these primitives extend +- **Dependencies**: Slices 1–3 (rows), Slice 5 (grant-price materiality), registry (`meteringUnit` declarations). + +### 1.5 Scope + +**In scope**: authoring + publish validation + snapshot freezing of the five primitives; +the reservation-variant registration into Slice 3's `FixtureGate`; grant-price scoping per +`(currency, region)`; composite formula-as-data schema + referential/self-reference checks; +`discountRef` resolution check; floor typing + placement warning. + +**Out of scope**: reservation evaluation/matching (Tariffs step 6); balance ledger, +drawdown, zero cut-off, auto-recharge execution (Billing/Rating — **GA gate** for the +sellable path); composite formula evaluation (Tariffs); discount authoring/evaluation/ +stacking (Promotions/Tariffs); negotiated RI rates (Contracts); floor enforcement +(Subscriptions for `purchase`; Tariffs/Rating for `usage`). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-10-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| A1 | Reservation = attributes | `reservedRate`/`reservationFlavor` live **on** the usage row (never a second row) so `(meter, dimensionKey)` stays injective; aligned field-for-field with Tariffs `reservationMatch` | PRD §17.7 | +| A2 | Reserved row is a usage row | `billingGranularity` REQUIRED; `tierAggregationWindow` REQUIRED only when tiered (the usage-only placement rule applies normally) | PRD §17.7 | +| A3 | Grant is not a Price row | The prepaid grant is a **plan-attached primitive** with its own per-`(currency, region)` price — no `chargeKind`, not on the canonical scope key | PRD §17.7 | +| A4 | Formula as data | Composite formula = operands + operator/weights as data (never executable code); versioned with the plan revision | PRD §17.7 | +| A5 | Prepaid GA gate | Grants are definable/publishable but **not sellable** until Billing/Rating balance execution exists (tracked GA gate) | PRD §13 | + +### 1.7 Naming & Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `ReservationValidator` | Registered rules: A1/A2 shapes; fixture registration for the reservation variant | +| `GrantValidator` | Registered rules: grant fields, expiry set, published `creditUnit`, per-`(currency, region)` price scoping (`category = prepaid`), promotional shape (no price rows, no auto-recharge), `applicability` (published usage meters of the plan, `creditUnit` consistency, materialized default) | +| `CompositeMeterValidator` | Registered rules: ≥ 2 published constituents, formula-as-data well-formedness, no self-reference, one output unit | +| `DiscountRefResolver` | Referential-integrity check against the registered external instrument | +| `FloorTypeValidator` | `purchase` vs `usage` typing + in-band placement warning | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s10["Slice 10 — Advanced Primitives"] + RV["ReservationValidator"] + GV["GrantValidator"] + CMV["CompositeMeterValidator"] + DRR["DiscountRefResolver"] + FTV["FloorTypeValidator"] + end + REG["Registry
published meteringUnits"] + PROM["Promotions
external discount instrument"] + FXG["FixtureGate (Slice 3)"] + FND["Foundation (Slice 1)"] + REG --> CMV + PROM --> DRR + RV --> FXG + RV --> FND + GV --> FND + CMV --> FND + DRR --> FND + FTV --> FND +``` + +## 2. Actor Flows (CDSL) + +### Author Advanced Primitives + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-primitives-author` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` + +**Success Scenarios**: +- A usage row gains `reservedRate`/`reservationFlavor`; a plan gains a prepaid grant or a composite-meter definition; a row gains `discountRef` or a typed `minQtyThreshold` — each validated at publish, frozen in the snapshot + +**Error Scenarios**: +- Reservation on a non-usage row → 422; grant without `expiryPolicy` / with unpublished `creditUnit` / unscoped price on a multi-market plan / promotional carrying a price or auto-recharge / `applicability` naming an unpublished or non-usage meter → 422; composite with < 2 or unpublished constituents / self-reference → 422; unresolvable `discountRef` → 422; untyped floor → 422 + +**Steps**: +1. [ ] - `p1` - Primitives author through the Slice 2/3 plan/price PATCH surfaces (plan-attached: grant, composite; row-attached: reservation, discountRef, floor) - `inst-ad-author` +2. [ ] - `p1` - Publish: the five validators run in the Foundation pipeline; the reservation variant additionally passes Slice 3's `FixtureGate` - `inst-ad-validate` +3. [ ] - `p1` - **RETURN** definitions frozen in `pricingSnapshotRef`; evaluation/execution downstream - `inst-ad-return` + +## 3. Processes / Business Logic (CDSL) + +### Reserved-Capacity Attributes + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-reserved` + +**Steps**: +1. [ ] - `p1` - `reservedRate` (≥ 0, row currency) + `reservationFlavor` (`consumption | capacity`) are attributes **on the single usage row**, alongside the on-demand price/tiers (A1 — never a second row, never a second `(meter, dimensionKey)` line) - `inst-rv-attrs` +2. [ ] - `p1` - The row remains a usage row (A2): `billingGranularity` REQUIRED; `tierAggregationWindow` REQUIRED only when tiered - `inst-rv-usage` +3. [ ] - `p1` - The reserved/allocated **quantity** is runtime input (OSS/Contracts entitlement); the catalog neither meters nor allocates nor computes the charge; Tariffs step 6 sources the self-service rate from the snapshot - `inst-rv-runtime` +3a. [ ] - `p1` - **Reservation × tiers (normative, money-affecting):** the matched/allocated reserved quantity is **excluded** from the on-demand tier counter `Q` — only the on-demand **remainder** enters the row's bands (150K used with a 100K reservation: 100K at `reservedRate`, the remainder's `Q` starts at 0, not 100K). Frozen semantics; the reservation joint fixture MUST include a tiered-remainder scenario - `inst-rv-tier-q` +4. [ ] - `p1` - The reservation variant requires its own joint golden fixture before publish (registered into Slice 3's `FixtureGate`); negotiated RI-style rates stay in Contracts (boundary) - `inst-rv-fixture` + +### Prepaid Credit Grant + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-prepaid-grant` + +**Steps**: +1. [ ] - `p2` - Grant fields: `grantAmount > 0`; `creditUnit` = ISO 4217 currency **or** a **published** `meteringUnit` (unpublished fails); `expiryPolicy` explicitly `never` or `days(N>0)` (no implicit never; **`days(N)` anchors at grant issuance** — the purchase or recharge instant, UTC); `autoRechargeAllowed` bool - `inst-pg-fields` +1a. [ ] - `p2` - **`category` (D-43)**: `prepaid` (default — purchased; the `inst-pg-price` rules apply) or `promotional` (issued **free** — grant-price rows MUST be absent, `GRANT_PROMO_PRICE_FORBIDDEN`; `autoRechargeAllowed` MUST be false, `GRANT_PROMO_AUTORECHARGE` — a recharge is a purchase); publish **warns** on `promotional` + `expiryPolicy = never` (`GRANT_PROMO_NO_EXPIRY` — likely authoring error); frozen in the snapshot - `inst-pg-category` +1b. [ ] - `p2` - **`applicability` (D-43)**: which of the plan's charge lines the credit may offset at drawdown — `all_usage` (default) or an explicit set of **published** `meteringUnit` ids; every target MUST be a usage line of the grant-bearing plan (credit never offsets `one_time_setup` or recurring rows — launch rule); when `creditUnit` is a `meteringUnit`, the set MUST stay within that unit's meters (absent ⇒ that unit's meters). Publish **materializes** the resolved set into the snapshot — the executor never infers scope - `inst-pg-applicability` +1c. [ ] - `p2` - **`drawdownPriority` (D-43)**: optional int ≥ 0 (lower draws first) — an authored **default rank**, frozen in the snapshot. The **effective** cross-grant order at drawdown is **Billing-owned**, resolved over frozen inputs by the normative tie-break chain: `drawdownPriority` → category (`promotional` before `prepaid`) → earlier expiry → earlier issuance → `grantId` (a deterministic total order; the catalog never orders live balances) - `inst-pg-priority` +2. [ ] - `p2` - (`category = prepaid` only) Grant **price** is authored per `(currency, region)` like a price row scope — but the grant is a plan-attached primitive with **no** `chargeKind`, not on the canonical scope key (A3); a single unscoped price on a multi-`(currency, region)` plan fails publish, and the grant-price set MUST cover **every** `(currency, region)` the plan publishes sellable rows for — a missing market fails publish (`GRANT_PRICE_NOT_COVERED`) - `inst-pg-price` +3. [ ] - `p2` - Grant-price changes — and `category` / `applicability` / `drawdownPriority` changes on a published grant — flow through the **same material-change policy** (registered into Slice 5's evaluator) - `inst-pg-material` +4. [ ] - `p2` - The catalog **never** persists balance or computes drawdown; the definition freezes into the snapshot; the sellable path is **GA-gated** on Billing/Rating balance execution (A5) — publishable now, with a **prepaid-execution GA-gate flag** on the read model consumed by the Slice 7 sellability gate. **Mechanics (D-29):** the flag derives at publish from the named platform/tenant GA signal **"prepaid balance execution GA"** (owner: Billing/Rating; tracked on the program board per PRD §13); it applies to **every scope key of the grant-bearing plan** (plan-level — matching PRD AC #87); clearing follows the Slice 4 pattern (`inst-td-clear`): a **re-publish through the pipeline + approval** once the signal is GA — never a silent flag flip - `inst-pg-gagate` + +### Derived (Composite) Meter + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-composite-meter` + +**Steps**: +1. [ ] - `p2` - Persist ≥ 2 **published** constituent `meteringUnit` ids (registry-declared); any unpublished constituent fails publish - `inst-cm-constituents` +2. [ ] - `p2` - Persist the formula **as data** (A4): operands + operator/weights — a declarative schema, not executable code; self-reference (output unit among constituents, direct or transitive) fails - `inst-cm-formula` +2a. [ ] - `p2` - **Output-unit ownership (D-32):** the derived output unit is **declared to the registry like any `meteringUnit`** (one meter namespace — Rating recognizes it through the same registry lookup as base units); the catalog persists the registry-declared unit id + the formula binding, never a catalog-private unit name. Part of the registry joint contract (PRD §15) - `inst-cm-output-unit` +3. [ ] - `p2` - One declared **output unit**: the price row rates the composite as **one line**, satisfying Slice 2's meter injectivity as one output unit - `inst-cm-output` +4. [ ] - `p2` - The definition freezes into `pricingSnapshotRef`; Tariffs evaluates — the catalog never computes the formula result - `inst-cm-frozen` + +### Discount Reference Hook + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-discount-ref` + +**Steps**: +1. [ ] - `p2` - Optional `discountRef` validates **referential integrity only**: it must resolve to a registered external instrument (Promotions/Tariffs-owned); absence never blocks publish - `inst-dr-referential` +2. [ ] - `p2` - The catalog does not author, evaluate, or stack the discount; the ref persists on the snapshot; a clone copies it only if it still resolves (else dropped with an operator notice — Slice 12 clone rule) - `inst-dr-boundary` + +### Minimum-Quantity Floor Typing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-floor-typing` + +**Steps**: +1. [ ] - `p2` - A `minQtyThreshold` MUST declare its floor type: `purchase` (Subscriptions rejects orders below — not silently zero) or `usage` (Tariffs/Rating treats below-floor usage as ineligible, failing closed — never silent zero-rating); untyped fails publish - `inst-ft-typed` +1a. [ ] - `p2` - **The fallback is authored, not implied:** a `usage` floor MUST declare its fallback on the row; at launch the only supported value is **`exception`** — the below-floor usage line fails closed into the rating exception path (visible, resolvable), never silently zero-rated and never silently charged. Richer fallbacks (e.g. an alternative row) are Future; the declared fallback freezes in the snapshot - `inst-ft-fallback` +2. [ ] - `p2` - Both MAY be set on one row (distinct fields); type + value freeze in the snapshot - `inst-ft-both` +3. [ ] - `p2` - Publish **warns** when a floor falls inside a non-zero-priced band (likely authoring error: the floor hides paid quantity) - `inst-ft-warn` + +### Trailing-Tier Qualification (Tier Rate-Lock) + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-trailing-tier` + +**Steps**: +1. [ ] - `p2` - A tiered usage row MAY set **`tierQualificationWindow`** (`current` | `trailing_month`) — a **third, distinct window** from `tierAggregationWindow` (when the in-window `Q` counter resets) and `billingGranularity` (billing cadence). `current` (default) preserves Slice 3 behaviour exactly (tier from this window's own `Q`) - `inst-tt-window` +2. [ ] - `p2` - `trailing_month`: the **rate tier is qualified by the prior calendar month's total** `Q`, not the current window — the band the trailing total falls into (single-band **volume**-style selection) sets **one rate for the whole current period**; billing then applies that locked rate to actual usage at `billingGranularity`. Canonical case: PaaS egress where last month's volume sets `$/GiB` and the current month is billed hourly on actual traffic - `inst-tt-qualify` +3. [ ] - `p2` - The qualified rate is a **period rate-lock** frozen into `pricingSnapshotRef` for the current period (the tier analogue of the FX rate-lock): Tariffs applies the locked rate; the catalog authors the window and never computes the qualification or the trailing aggregate (Rating supplies the trailing total) - `inst-tt-lock` +4. [ ] - `p2` - `trailing_month` is **usage-tiered only** (`graduated`/`volume`); presence on `flat`/`per_unit`/`package` or any non-usage row fails publish (`TIER_QUAL_ON_NON_TIERED`, 422) - `inst-tt-forbidden` +5. [ ] - `p2` - **Bootstrap** (first period, no trailing history) resolves to the **lowest tier** unless the plan authors an explicit bootstrap tier; the resolved bootstrap choice freezes in the snapshot so replay is deterministic - `inst-tt-bootstrap` +6. [ ] - `p2` - The qualification window and the resolved locked rate are part of the **joint Rating contract** (PRD §consumer-contracts): Rating computes the trailing aggregate and re-qualifies at each period boundary; Tariffs reads the locked rate from the pin - `inst-tt-joint` + +## 4. States (CDSL) + +No slice-owned state machine. The prepaid grant's sellability rides the GA-gate flag +(Slice 4 pattern, `inst-pg-gagate`); primitives otherwise ride the row/plan lifecycle. + +## 5. API Surface + +No new endpoints: primitives author through the Slice 2/3 surfaces (plan PATCH: grant, +composite; price-row PATCH: reservation, `discountRef`, floors). + +**Problem responses (RFC 9457):** `RESERVATION_ON_NON_USAGE` (422), +`GRANT_EXPIRY_MISSING` (422), `CREDIT_UNIT_UNPUBLISHED` (422), `GRANT_PRICE_UNSCOPED` (422), +`GRANT_PRICE_NOT_COVERED` (422 — a sold `(currency, region)` without a grant price), +`GRANT_PROMO_PRICE_FORBIDDEN` (422 — a `promotional` grant carrying price rows), +`GRANT_PROMO_AUTORECHARGE` (422 — a `promotional` grant with `autoRechargeAllowed`), +`GRANT_APPLICABILITY_UNPUBLISHED` (422 — an applicability meter unknown or unpublished), +`GRANT_APPLICABILITY_INELIGIBLE` (422 — a target that is not a usage line of the plan, or an +empty resolved set), `GRANT_APPLICABILITY_UNIT_MISMATCH` (422 — a metered-`creditUnit` grant +scoped outside that unit's meters), +`COMPOSITE_CONSTITUENT_UNPUBLISHED` (422), `COMPOSITE_SELF_REFERENCE` (422), +`DISCOUNT_REF_UNRESOLVED` (422), `FLOOR_TYPE_MISSING` (422), `FLOOR_FALLBACK_MISSING` (422), +`TIER_QUAL_ON_NON_TIERED` (422 — `tierQualificationWindow = trailing_month` on a non-tiered or non-usage row); warnings: +`FLOOR_INSIDE_PRICED_BAND`, `GRANT_PROMO_NO_EXPIRY` (a `promotional` grant with `expiryPolicy = never`). + +## 6. Data Model + +Columns on Foundation-owned tables + one slice table (`pricing_` prefix per Foundation §3.7): + +**`pricing_price` (Slice-10 columns)**: + +| Column | Type | Notes | +|--------|------|-------| +| `reserved_rate_minor` | `bigint` | ≥ 0; usage rows only | +| `reservation_flavor` | `enum` | `consumption \| capacity`; present iff `reserved_rate_minor` is | +| `discount_ref` | `string` | optional; referential-validated | +| `min_qty_purchase` | `bigint` | purchase floor (order-time, Subscriptions) | +| `min_qty_usage` | `bigint` | usage floor (eligibility, Tariffs/Rating) | +| `min_qty_usage_fallback` | `enum` | REQUIRED when `min_qty_usage` set; launch: `exception` only (rating exception path); frozen in snapshot | + +**`pricing_plan` (Slice-10 columns)** — `prepaid_grant` (`jsonb`: `grantAmount`, +`creditUnit`, `expiryPolicy`, `autoRechargeAllowed`, `category` — `prepaid | promotional`, +default `prepaid`; `applicability` — the **materialized** usage-meter id set or `all_usage`; +`drawdownPriority` — optional int ≥ 0) + **`pricing_grant_price`** rows. + +**`pricing_grant_price`** (FK `plan_id`): `currency`, `region`, `price_minor` (≥ 0) — the +grant's purchase price per market (A3: not on the canonical scope key, no `chargeKind`); rows +REQUIRED iff `category = prepaid` (price rows on a `promotional` grant fail publish). + +**`pricing_composite_meter`** (PK `composite_id`; FK `plan_id`): `output_unit`, +`constituent_units` (`jsonb`, ≥ 2 published ids), `formula` (`jsonb` — operands + +operator/weights, A4), `revision`. + +Key constraints: `CHECK (reservation_flavor IS NULL) = (reserved_rate_minor IS NULL)`; +`CHECK (grant fields complete when prepaid_grant set)` at publish; composite self-reference +check application-level (graph walk over `constituent_units` vs `output_unit`). + +## 7. Events & Alarms + +No new frozen event names (primitives ride `PlanUpdated`/`PriceUpdated`). Alarms: +`pricing.prepaid.ga_gate_active` (Info gauge — published grants awaiting balance execution, +mirrors Slice 4's GA-gate visibility), `pricing.discount.ref_dangling` (Warn — a published +`discountRef` whose instrument was retired upstream; surfaced for remediation, rating is +unaffected since evaluation is downstream), `pricing.composite.constituent_retired` (Info — a +published composite meter references a constituent `meteringUnit` retired upstream; the next +publish is blocked per the composite AC, this alarm surfaces the already-published case for +remediation via re-publish with a corrected formula). + +## 8. Definitions of Done + +### Reserved Capacity + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-reserved` + +A usage row **MUST** support `reservedRate`/`reservationFlavor` as same-row attributes +(usage-row rules intact; quantity runtime-supplied; charge computed by Tariffs step 6 from +the snapshot), with the reservation variant fixture-gated before publish and negotiated RI +rates left to Contracts. + +**Implements**: `cpt-cf-bss-pricing-algo-reserved`, `cpt-cf-bss-pricing-flow-primitives-author` + +**Touches**: +- DB: `pricing_price` (reservation columns) +- Entities: `ReservationValidator` + +### Prepaid Grant + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-prepaid` + +A plan **MAY** declare a prepaid grant with complete fields (explicit expiry, published +`creditUnit`, per-`(currency, region)` price for `category = prepaid`; a `promotional` grant +is free — no price rows, no auto-recharge), a **materialized** usage-line `applicability` +scope, and an optional `drawdownPriority` default rank; all frozen in the snapshot, +grant-price/`category`/`applicability`/rank changes material, **no** balance/drawdown or +cross-grant ordering here (the effective order is Billing's per the D-43 tie-break chain), +and the sellable path GA-gated on Billing/Rating execution. + +**Implements**: `cpt-cf-bss-pricing-algo-prepaid-grant` + +> **D-45 consumer**: an `includedAllowance.rolloverPolicy = carry(maxPeriods)` on a usage row +> publish-materializes into this grant machinery (a free per-period grant, `applicability` = the +> row's meter, expiry = the carry horizon) — the allowance never introduces a second drawdown path. + +**Touches**: +- DB: `pricing_plan.prepaid_grant`, `pricing_grant_price` +- Entities: `GrantValidator` + +### Derived Meter + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-composite` + +A composite meter **MUST** persist ≥ 2 published constituents, the formula as data, and one +output unit (injectivity preserved); self-reference and unpublished constituents fail; the +frozen definition is evaluated by Tariffs only. + +**Implements**: `cpt-cf-bss-pricing-algo-composite-meter` + +**Touches**: +- DB: `pricing_composite_meter` +- Entities: `CompositeMeterValidator` + +### Discount Hook & Floor Typing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-discount-floor` + +`discountRef` **MUST** validate referential integrity only (absence never blocks); +`minQtyThreshold` **MUST** declare `purchase`/`usage` typing (untyped fails; in-band +placement warns) and a `usage` floor **MUST** declare its fallback (launch: `exception`); +all freeze in the snapshot with enforcement downstream. + +**Implements**: `cpt-cf-bss-pricing-algo-discount-ref`, `cpt-cf-bss-pricing-algo-floor-typing` + +**Touches**: +- DB: `pricing_price` (discount/floor columns) +- Entities: `DiscountRefResolver`, `FloorTypeValidator` + +### Trailing-Tier Qualification + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-trailing-tier` + +A tiered usage row **MAY** set `tierQualificationWindow` (`current` | `trailing_month`), +distinct from `tierAggregationWindow` and `billingGranularity`. `trailing_month` qualifies the +rate tier from the **prior month's total** (single-band selection), locks that rate for the +current period into `pricingSnapshotRef`, and bills actual usage at `billingGranularity`; +`trailing_month` on a non-tiered/non-usage row fails publish; first-period bootstrap resolves to +the lowest tier (or an authored bootstrap) and freezes. Tariffs applies the locked rate; Rating +supplies the trailing aggregate and re-qualifies at each period boundary. + +**Implements**: `cpt-cf-bss-pricing-algo-trailing-tier` + +**Touches**: +- DB: `pricing_price` (`tier_qualification_window` column) +- Entities: `TierQualificationValidator` + +## 9. Acceptance Criteria + +Unit: + +- [ ] Reservation shape matrix (non-usage row rejected; flavor without rate rejected; granularity still required); grant field matrix (implicit-never rejected; unpublished creditUnit rejected; unscoped price on 2-market plan rejected; a 3-market plan with grant prices for only 2 rejected — `GRANT_PRICE_NOT_COVERED`; a `promotional` grant with a price row or auto-recharge rejected; an applicability naming an unpublished meter or a non-usage line rejected; metered-`creditUnit` applicability outside the unit rejected; `promotional` + `never` expiry warns); composite self-reference (direct + transitive) rejected; untyped floor rejected; `usage` floor without a declared fallback rejected; in-band floor warns + +Integration (testcontainers): + +- [ ] A reserved usage row publishes only with the reservation fixture green (FixtureGate); the snapshot carries rate + flavor +- [ ] A prepaid grant publishes with per-market prices; a grant-price change routes material (Slice 5); a `promotional` grant publishes with **no** price rows; the snapshot carries `category`, `drawdownPriority`, and the **materialized** `applicability` (authored `all_usage` resolved to the plan's usage meters) +- [ ] A composite (vCPU + RAM → one output unit) publishes as one priced line (injectivity holds); retiring a constituent upstream blocks the next publish +- [ ] A `discountRef` to a nonexistent instrument fails; removing the ref publishes fine + +## 10. Non-Functional Considerations + +- **Performance**: all validation publish-path; composite formula size is bounded by the plan/tier size caps (provisional NFR). +- **Observability**: `pricing_primitive_validation_failures_total{primitive}`, the two §7 gauges. +- **Security & AuthZ**: grant-price and reserved-rate changes are price mutations — Slice 5 materiality; composite definitions are structural (versioned + approvable). +- **Risks & open items**: prepaid balance execution absent (A5 — grants definable, not sellable; tracked GA gate with named owner per PRD §13) — when it lands, Billing MUST mirror the D-43 drawdown tie-break chain and the materialized `applicability` scope as a joint-contract line (drawdown placement vs discounts/tax is the same open — STRIPE-GAP-ANALYSIS G-4); Tariffs must land the sourcing change for self-service reserved rates (snapshot, not Contracts) + the joint fixture (PRD §17.2); the Promotions PRD still does not exist — `discountRef` is the committed day-1 hook, the durable owner remains Future. diff --git a/gears/bss/pricing/docs/design/11-lifecycle.md b/gears/bss/pricing/docs/design/11-lifecycle.md new file mode 100644 index 000000000..d1ceb9b27 --- /dev/null +++ b/gears/bss/pricing/docs/design/11-lifecycle.md @@ -0,0 +1,385 @@ + + + +# DESIGN — Lifecycle: Retirement & Migration (Slice 11) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Retire a Plan](#retire-a-plan) + - [Schedule a Migration](#schedule-a-migration) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Retirement](#retirement) + - [Scheduled Migration](#scheduled-migration) + - [Migration Safety Deltas](#migration-safety-deltas) + - [Legacy Snapshot Synthesis](#legacy-snapshot-synthesis) + - [Contract-Lock Protection](#contract-lock-protection) +- [4. States (CDSL)](#4-states-cdsl) + - [Migration Schedule State Machine](#migration-schedule-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Retirement DoD](#retirement-dod) + - [Migration DoD](#migration-dod) + - [Snapshot Synthesis DoD](#snapshot-synthesis-dod) + - [Contract Lock DoD](#contract-lock-dod) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **end of a plan's life**: **retirement** (block new subscriptions, +preserve in-flight snapshots, trigger the Slice 7-owned window-cancellation flow for +not-yet-active windows — never merely marking them invalid), **scheduled migration** to a +published target (`PlanMigrationScheduled` → Subscriptions creates effective-dated +`PlanLink`s; idempotent retry; cancellable before the effective date), **migration safety +deltas** (contract-locked exclusion, entitlement/add-on blocking deltas), and **legacy +snapshot synthesis** (`migrated-origin` — freezing a `pricingSnapshotRef` for subscriptions +that never had one). Posted invoices are never mutated; every path is snapshot/`PlanLink` +only. + +**Traces to**: `cpt-cf-bss-pricing-fr-plan-retirement`, +`cpt-cf-bss-pricing-fr-scheduled-migration`, `cpt-cf-bss-pricing-fr-migration-safety`, +`cpt-cf-bss-pricing-fr-contract-locked-protection` + +### 1.2 Purpose + +Let operators sunset and consolidate plans — the PRD's lifecycle-safety goal — with three +hard guarantees: an in-flight subscriber's economics never silently change (frozen snapshot +or explicit migration), a posted invoice is never touched, and a contract lock is never +broken. Industry-standard grandfathering/notice policies compose from this slice + Slice 7's +cutover, without SKU cloning. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-catalog-admin` | Orchestrates retirement + migration (`plan × retire` / `plan × migrate`) | +| `cpt-cf-bss-pricing-actor-finance-manager` | Confirms cancelled-window warnings; picks targets | +| `cpt-cf-bss-pricing-actor-subscriptions` | Consumes `PlanMigrationScheduled`; creates `PlanLink`s; executes re-binds | +| `cpt-cf-bss-pricing-actor-contracts` | Supplies contract locks (excluded set) | +| `cpt-cf-bss-pricing-actor-billing` | Never re-queried for posted periods (immutability boundary) | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.8, §17.5 (change mechanisms), §15 (migration notice-period open item) +- **Design**: [01-foundation.md](./01-foundation.md) — versioning/immutability (§4.3); [07-pricewindow-linkage.md](./07-pricewindow-linkage.md) — grandfathering, window mechanics; [05-governance.md](./05-governance.md) — `plan × retire/migrate` authz, `historical_import` grant +- **Dependencies**: Slices 1–7 (published plans, windows, grandfathering, governance). Retirement invokes Slice 7's window-cancellation flow (windows are gear-owned per D-03). + +### 1.5 Scope + +**In scope**: retirement (state transition + window-cancellation trigger + operator warning); +migration scheduling/cancellation + idempotent retry semantics; blocking-delta computation +(contract locks, entitlement overflow, add-on validity); `migrated-origin` snapshot synthesis +with provenance. + +**Out of scope**: `PlanLink` **execution** and subscription state (Subscriptions); the window +cancellation **mechanics** (Slice 7 owns the window machinery — we invoke); posted-invoice anything +(Billing); the grandfathering cutover itself (Slice 7 — a migration alternative); +customer-notice lead-time policy (open business item — the schedule carries the effective +date, policy sets it). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-11-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| M1 | No invoice mutation | 100% of migrations use snapshot/`PlanLink` paths; nothing re-opens a posted period | PRD §1.3 | +| M2 | Idempotent schedule | Re-triggering a migration produces no duplicate `PlanLink` requests for already-processed subscriptions (dedup key = `(migration_id, subscription)`) | PRD §6.8 | +| M3 | Cancellation boundary | Cancel-before-effective invalidates the scheduled event without touching already-migrated subscriptions | PRD §6.8 | +| M4 | Synthesis instant | Legacy synthesis freezes the published state **as of the trigger instant (UTC), frozen at execution**; provenance recorded (`migrated-origin`) | PRD §6.8 | +| M5 | Notice period | Configurable lead time (default 60–90 days) is an open Finance/GTM item; the design takes the effective date as input | PRD §15 | + +### 1.7 Naming & Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `RetirementOrchestrator` | The retire transition + Slice 7 window-cancellation trigger + operator warning surface | +| `MigrationScheduler` | Creates/cancels migration schedules; emits `PlanMigrationScheduled`; owns M2 idempotency | +| `DeltaAnalyzer` | Computes blocking deltas: contract-locked set, entitlement overflow, add-on validity | +| `SnapshotSynthesizer` | Builds + freezes a `migrated-origin` `pricingSnapshotRef` with provenance | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s11["Slice 11 — Lifecycle"] + RO["RetirementOrchestrator"] + MS["MigrationScheduler"] + DA["DeltaAnalyzer"] + SY["SnapshotSynthesizer"] + end + PW["Slice 7 window machinery
cancellation flow (gear-owned)"] + CON["Contracts
lock registry"] + FND["Foundation (Slice 1)
lifecycle states · snapshot · outbox"] + SUB["Subscriptions
PlanLink creation · re-bind execution"] + RO --> PW + DA --> CON + RO --> FND + MS --> FND + SY --> FND + FND -- "PlanRetired · PlanMigrationScheduled" --> SUB +``` + +## 2. Actor Flows (CDSL) + +### Retire a Plan + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-plan-retire` + +**Actor**: `cpt-cf-bss-pricing-actor-catalog-admin` (`plan × retire`) + +**Success Scenarios**: +- The plan transitions `published → retired`: new subscriptions blocked (sellability gate reads the state), existing snapshots preserved, `PlanRetired` emitted, Slice 7's cancellation flow invoked per not-yet-active window (each emitting `PriceWindowCancelled` + driving cache eviction) +- The operator is warned with the list of windows to be cancelled **before** confirm + +**Error Scenarios**: +- Retire with an in-flight migration targeting this plan → `RETIRE_TARGET_OF_MIGRATION` (409) +- Retire while the plan is referenced as a bundle component or as an add-on price-override target → `RETIRE_PLAN_REFERENCED` (409, references enumerated) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/plans/{planId}/retire (dry-run first: returns the cancelled-window preview **and any cutover unit to be unwound**, D-05) - `inst-rt-api` +2. [ ] - `p1` - Confirm: transition the plan state; **invoke** Slice 7's window-cancellation flow for every not-yet-active window (never merely mark invalid) — one local transaction with the state flip (D-03); active windows run to their natural end for in-flight subscribers. A **live cutover unit is unwound** in the same transaction (Slice 7 `inst-co-retirement-unwind`, D-05): predecessor window restored to its pre-cutover `effectiveTo`, scheduled copy/successor cancelled, unit closed as unwound; retirement with a live cutover is **always material** (Slice 5) - `inst-rt-cancel` +3. [ ] - `p1` - Emit `PlanRetired`; the read model flags the plan not-sellable (Slice 7 gate input) - `inst-rt-event` +4. [ ] - `p1` - **RETURN** 202; existing subscription snapshots untouched (M1) - `inst-rt-return` + +### Schedule a Migration + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-flow-migration-schedule` + +**Actor**: `cpt-cf-bss-pricing-actor-catalog-admin` (`plan × migrate`) + +**Success Scenarios**: +- A migration from a (typically retiring) plan to a **published** target is scheduled with an effective date; `DeltaAnalyzer` reports blocking deltas; `PlanMigrationScheduled` emits for Subscriptions to create effective-dated `PlanLink`s +- Retry is idempotent (M2); cancel-before-effective invalidates cleanly (M3) + +**Error Scenarios**: +- Unpublished/retired target → `MIGRATION_TARGET_INVALID` (422) +- Unresolved blocking deltas (entitlement overflow, invalid add-ons) → `MIGRATION_BLOCKED` (422, deltas enumerated) + +**Steps**: +1. [ ] - `p1` - API: POST /v1/pricing/migrations (**client-supplied `migration_id`**, source plan/revision, target `planId`, effective date, scope) — the create is idempotent on `migration_id` (mirroring Slice 12's `run_id` pattern): a timed-out client retry returns the original schedule, never a second one - `inst-ms-api` +2. [ ] - `p1` - `DeltaAnalyzer` computes: contract-locked subscriptions (reported, **excluded**, lock never broken), entitlement deltas, add-on deltas (invalid / missing-required) — blocking deltas must be resolved or explicitly scoped out - `inst-ms-deltas` +3. [ ] - `p1` - Emit `PlanMigrationScheduled` (idempotency key = `migration_id`; consumer dedup per `(migration_id, subscription)`, M2) - `inst-ms-emit` +4. [ ] - `p1` - Legacy subscriptions without a `pricingSnapshotRef` route through `SnapshotSynthesizer` (below) before their `PlanLink` is requested - `inst-ms-synth` +5. [ ] - `p1` - **RETURN** 202 (schedule ref); DELETE /v1/pricing/migrations/{id} before the effective date cancels (M3) - `inst-ms-return` + +## 3. Processes / Business Logic (CDSL) + +### Retirement + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-retirement` + +**Steps**: +1. [ ] - `p1` - `published → retired` blocks **new** subscriptions only; in-flight subscribers keep resolving their frozen snapshots and active windows until renewal/migration - `inst-re-block` +2. [ ] - `p1` - Not-yet-active windows: Slice 7's cancellation flow is **invoked** (each cancellation emits `PriceWindowCancelled` and drives its cache-eviction path) — marking-invalid without the event is forbidden (consumers would keep warm caches) - `inst-re-cancelflow` +3. [ ] - `p1` - The operator confirm screen lists every window to be cancelled (dry-run) - `inst-re-warn` +4. [ ] - `p1` - Retirement is a governed mutation (Slice 5): `plan × retire`, audited; retirement of a plan with active subscribers SHOULD pair with a migration schedule or an explicit grandfathering decision - `inst-re-governed` +5. [ ] - `p1` - **Referential guard:** retirement is rejected while the plan is referenced as a **bundle component** (`sum_of_parts`/`own_price` composition, Slice 8) or as an **add-on price-override target** (Slice 2) — the dry-run enumerates the referencing bundles/plans; remediation (re-compose or retire the referrer first) precedes the retire. Plans listing the retiree in `allowedChangeTargets` are enumerated as a **warning** (not a block, D-24): the edge goes inert — Subscriptions re-checks the target's lifecycle state at change time. `PriceOverlays` targeting the retiree are likewise enumerated as a **warning** (D-31): they go dangling-and-flagged (`pricing.priceoverlay.target_retired`), staying evaluable for in-flight subscribers - `inst-re-references` + +### Scheduled Migration + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-migration` + +**Steps**: +1. [ ] - `p1` - Target MUST be a **published** plan; the schedule carries source revision, target, effective date (M5 sets policy on the date), and scope (all / filtered subscriptions) - `inst-mg-target` +2. [ ] - `p1` - The catalog emits the schedule; **Subscriptions** creates effective-dated `PlanLink`s and executes — the catalog never mutates a subscription and never touches a posted invoice (M1). A migration **never re-charges** the target plan's `one_time_setup` row (setup is once-per-subscription-lifetime — Slice 2 `inst-cs-setup-timing`), and a migrated subscription enters the target's **first non-trial phase** (D-39 — a migration never grants a new `trial`; entering an `intro` phase is allowed; the entry-phase rule rides the `PlanMigrationScheduled` contract) - `inst-mg-boundary` +3. [ ] - `p1` - **Idempotency (M2):** re-triggering the same `migration_id` re-emits without duplicating `PlanLink` requests for already-processed subscriptions (the event carries the dedup contract; Subscriptions honors `(migration_id, subscription)`) - `inst-mg-idem` +4. [ ] - `p1` - **Cancellation (M3, D-38):** the schedule invalidates via read-model state + audit (no new event name, per §7); already-migrated subscriptions are unaffected. **Propagation is a state handshake, not a wall clock**: Subscriptions MUST re-read the schedule state immediately **before beginning execution** and per processing batch thereafter — it never starts (or continues) against a cancelled record, closing the T-ε race; the catalog accepts a cancel in `scheduled`/`in_progress` and rejects only `completed` (`MIGRATION_COMPLETED`) - `inst-mg-cancel` + +### Migration Safety Deltas + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-migration-deltas` + +**Steps**: +1. [ ] - `p2` - **Contract-locked** subscriptions: reported and **excluded** — the lock is never broken (Contracts supplies the lock set) - `inst-md-locks` +2. [ ] - `p2` - **Entitlement deltas**: target grants < source grants (overflow risk) surface as **blocking** deltas; the operator resolves (change target / scope out / accept via explicit override where policy allows) - `inst-md-entitlements` +3. [ ] - `p2` - **Add-on deltas**: subscribers whose add-ons become invalid on the target, or who lack a target-required add-on — blocking - `inst-md-addons` +3a. [ ] - `p1` - **Boundary deltas (K3 enforcement):** for every in-scope subscription, the target MUST cover the subscription's **frozen `(currency, region)` pair** with a published row of **matching frequency** — a mismatch is a **blocking** delta (cross-currency/region/frequency moves are cancel + new, never an in-place `PlanLink`). Additionally, subscribers bound to an `existing_grandfathered` row are surfaced **informationally** (migration takes them off legacy pricing — the operator sees the price impact before confirm) - `inst-md-boundary` +4. [ ] - `p2` - Deltas compute against the **published read model** of both plans (no draft reads) - `inst-md-published` + +### Legacy Snapshot Synthesis + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-snapshot-synthesis` + +**Steps**: +1. [ ] - `p2` - For a subscription with **no** `pricingSnapshotRef`: synthesize one from the published plan state **as of the trigger instant (UTC), frozen at execution** (M4) - `inst-sy-freeze` +2. [ ] - `p2` - Provenance record: source `planId`/revision, resolved price ids, snapshot instant, trigger (`migration` | `first-rating`), acting principal — marked **`migrated-origin`** - `inst-sy-provenance` +3. [ ] - `p2` - Synthesis is the sanctioned **consumer** of the Slice 5 backdating path where historical reference rows are needed (`historical_import × write` + reason + audit + row-shape pipeline + always-material approval — D-13; zero downstream billable effect) - `inst-sy-backdate` +4. [ ] - `p1` - **`first-rating` trigger is never inline:** when Rating meets a subscription with no snapshot, the rating line **fails closed into the rating exception path**; synthesis then runs as a separate audited step (automated remediation job or operator), and rating **retries** against the frozen result. Synthesis never executes inline on the rating hot path (it is heavyweight, audited, and grant-gated) - `inst-sy-firstrating` + +### Contract-Lock Protection + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-algo-contract-lock` + +**Steps**: +1. [ ] - `p1` - While an active contract references a plan revision, **structural mutation is rejected** (`CONTRACT_LOCKED`, 409) directing the operator to a new revision or contract expiry - `inst-cl-reject` +2. [ ] - `p1` - Contract-locked subscriptions are excluded from every scheduled migration (per `inst-md-locks`) - `inst-cl-exclude` +3. [ ] - `p1` - The lock set resolves from Contracts at validation time **and is re-resolved at execution start** (D-36 — schedule-time state can be months stale; `inst-mst-start`); an integration boundary — a lock-registry outage fails the mutation closed - `inst-cl-source` + +## 4. States (CDSL) + +### Migration Schedule State Machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-state-migration` + +**States**: scheduled, in_progress, completed, cancelled +**Initial State**: scheduled (deltas resolved, event emitted) + +**Transitions**: +1. [ ] - `p1` - **FROM** scheduled **TO** in_progress **WHEN** the effective date passes and Subscriptions begins `PlanLink` execution. **Execution-time re-validation (D-36):** on this transition the catalog **re-resolves** the contract-lock set and the boundary deltas against fresh state — newly-locked subscriptions are **excluded** (appended to the completion record; a lock is never broken however stale the schedule), and a newly-broken boundary (the target lost its frozen `(currency, region)`/frequency coverage) fails that subscription's `PlanLink` **closed** into the migration exception list - `inst-mst-start` +2. [ ] - `p1` - **FROM** scheduled **TO** cancelled **WHEN** cancelled before the effective date (M3; nothing executed yet) - `inst-mst-cancel` +2a. [ ] - `p1` - **FROM** in_progress **TO** cancelled **WHEN** the operator stops a partially-executed run (D-34 — the stop-the-bleeding control): **further** `PlanLink` processing halts; already-migrated subscriptions are unaffected; the partial (migrated / not-attempted) sets are listed on the record. Only a **completed** run is uncancellable - `inst-mst-cancel-inflight` +3. [ ] - `p1` - **FROM** in_progress **TO** completed **WHEN** Subscriptions reports the scope processed (excluded contract-locked set listed on the completion record) - `inst-mst-complete` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | AuthZ | +|--------|------|---------|-------------|-------| +| `POST` | `/v1/pricing/plans/{planId}/retire` | Dry-run + confirm retirement | per revision | `plan × retire` | +| `POST` | `/v1/pricing/migrations` | Schedule a migration (deltas computed) | `migration_id` (M2) | `plan × migrate` | +| `DELETE` | `/v1/pricing/migrations/{id}` | Cancel a `scheduled` or `in_progress` migration (D-34; a `completed` one is uncancellable) | — | `plan × migrate` | +| `GET` | `/v1/pricing/migrations/{id}` | Schedule + delta report + progress | — | `plan × read` | + +**Problem responses (RFC 9457):** `RETIRE_TARGET_OF_MIGRATION` (409), +`RETIRE_PLAN_REFERENCED` (409, references enumerated — bundle component / add-on +price-override target), `MIGRATION_TARGET_INVALID` (422), `MIGRATION_BLOCKED` (422, deltas +enumerated), `MIGRATION_COMPLETED` (409 — cancel of a completed run; replaces the pre-D-34 `MIGRATION_ALREADY_EFFECTIVE`), `CONTRACT_LOCKED` (409). + +## 6. Data Model + +Slice-owned tables (`pricing_` prefix per Foundation §3.7): + +**`pricing_migration`** (PK `migration_id`): + +| Column | Type | Notes | +|--------|------|-------| +| `source_plan_id` / `source_revision` | `uuid`/`int` | the retiring side | +| `target_plan_id` | `uuid` | MUST be published | +| `effective_at` | `timestamptz` | UTC; M5 policy feeds it | +| `scope` | `jsonb` | all / filter; excluded contract-locked set recorded | +| `state` | `enum` | `scheduled \| in_progress \| completed \| cancelled` | +| `delta_report` | `jsonb` | contract-locked / entitlement / add-on deltas at schedule time | + +**`pricing_snapshot_provenance`** (PK `provenance_id`) — the `migrated-origin` record: +`subscription_ref`, `source_plan_id`/`revision`, resolved price ids, `snapshot_instant` +(UTC), `trigger` (`migration | first-rating`), `acting_principal`. + +Retirement itself is the Foundation `pricing_plan.lifecycle_state` transition + audit; +window cancellations run in Slice 7's gear-owned window store (`pricing_price_window`). + +## 7. Events & Alarms + +Frozen names: **`PlanRetired`**, **`PlanMigrationScheduled`** (both in the Foundation event +set; the migration-cancelled signal rides the schedule's read-model state + audit, not a new +event name). Alarms: `pricing.migration.stalled` (Warn — `in_progress` past an expected +completion horizon), `pricing.migration.blocked_total` (Info counter — schedule attempts +rejected with `MIGRATION_BLOCKED`; unresolved blocking deltas never persist a schedule, so +this counts rejections, not waiting schedules). + +## 8. Definitions of Done + +### Retirement DoD + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-retirement` + +Retirement **MUST** block new subscriptions, preserve existing snapshots, emit `PlanRetired`, +and trigger Slice 7's gear-owned window-cancellation flow per not-yet-active window (one local transaction, D-03; with `PriceWindowCancelled` + +cache eviction; never mark-invalid), warning the operator with the cancellation list before +confirm. + +**Implements**: `cpt-cf-bss-pricing-flow-plan-retire`, `cpt-cf-bss-pricing-algo-retirement` + +**Touches**: +- API: `POST /v1/pricing/plans/{planId}/retire` +- DB: `pricing_plan.lifecycle_state`, `pricing_price_window` +- Entities: `RetirementOrchestrator` + +### Migration DoD + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-migration` + +Scheduled migration **MUST** target a published plan, emit `PlanMigrationScheduled` for +`PlanLink` creation without posted-invoice mutation, retry idempotently (no duplicate +`PlanLink` requests), and cancel-before-effective without affecting already-migrated +subscriptions. + +**Implements**: `cpt-cf-bss-pricing-flow-migration-schedule`, `cpt-cf-bss-pricing-algo-migration`, `cpt-cf-bss-pricing-state-migration` + +**Touches**: +- API: `POST/DELETE /v1/pricing/migrations*` +- DB: `pricing_migration` +- Entities: `MigrationScheduler` + +### Snapshot Synthesis DoD + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-snapshot-synthesis` + +For a legacy subscription without a snapshot, the system **MUST** synthesize and freeze a +`migrated-origin` `pricingSnapshotRef` from published state as of the trigger instant (UTC, +frozen at execution) with the full provenance record; rating a legacy subscription **before** +synthesis completes fails closed into the exception path and retries against the frozen +result (the first-rating trigger, `inst-sy-firstrating`). + +**Implements**: `cpt-cf-bss-pricing-algo-snapshot-synthesis` + +**Touches**: +- DB: `pricing_snapshot_provenance` +- Entities: `SnapshotSynthesizer` + +### Contract Lock DoD + +- [ ] `p1` - **ID**: `cpt-cf-bss-pricing-dod-contract-lock` + +Structural mutation of a contract-referenced plan revision **MUST** be rejected (new revision +or contract expiry directed); contract-locked subscriptions **MUST** be excluded from every +scheduled migration (reported, never broken); a lock-registry outage fails closed. The delta +report **MUST** classify locked, entitlement, add-on, **and boundary** deltas (a target +missing the frozen `(currency, region)` or frequency row is a **blocking** delta, +`inst-md-boundary`). + +**Implements**: `cpt-cf-bss-pricing-algo-contract-lock`, `cpt-cf-bss-pricing-algo-migration-deltas` + +**Touches**: +- DB: `pricing_migration.delta_report` +- Entities: `DeltaAnalyzer` + +## 9. Acceptance Criteria + +Unit: + +- [ ] Retire blocks new-subscription sellability but leaves existing snapshot resolution; migration target matrix (draft/retired target rejected); delta classification (locked/entitlement/add-on/boundary — a target missing the frozen `(currency, region)`/frequency row is blocking); cancel-after-effective rejected; synthesis provenance completeness + +Integration (testcontainers): + +- [ ] Retirement cancels only not-yet-active windows (active ones run out), emits `PlanRetired`, and the operator dry-run lists exactly the cancelled set +- [ ] Retiring a plan with an approved-not-yet-effective cutover unwinds it atomically: the predecessor window's `effectiveTo` is restored, copy/successor windows cancelled, the unit closed as unwound — no instant is left uncovered for in-flight subscribers; the retirement required two-person approval (always material) +- [ ] A migration re-trigger with the same `migration_id` produces no duplicate `PlanLink` requests (consumer-side dedup contract honored) +- [ ] Cancel before effective invalidates; cancelling an `in_progress` run halts further processing with the partial sets listed (already-migrated unaffected); cancel of a `completed` run → 409 (D-34) +- [ ] At execution start the lock set + boundary deltas re-resolve: a subscription locked after scheduling is excluded (appended to the record); a target that lost coverage fails that subscription's `PlanLink` closed into the exception list (D-36) +- [ ] A migrated subscription enters the target's first non-trial phase — a target with a `trial` phase never grants a new trial (D-39) +- [ ] A legacy subscription gets a `migrated-origin` snapshot with provenance; a second synthesis attempt is idempotent (same frozen ref) +- [ ] Rating a snapshot-less legacy subscription fails closed into the exception path; after synthesis the retry succeeds against the frozen ref +- [ ] Structural mutation of a contract-locked revision → 409; the same plan mutates fine after lock expiry + +## 10. Non-Functional Considerations + +- **Performance**: delta analysis is a batch computation at schedule time (not order-time); migration fan-out throughput is bounded by the event pipeline, with progress visible via the schedule state. +- **Observability**: `pricing_migrations{state}` gauge, `pricing_migration_excluded_locked_total`, `pricing_retirements_total`, stalled-migration alarm. +- **Security & AuthZ**: `plan × retire` / `plan × migrate` (Slice 5 catalog); retirement and migration are audited governed mutations; synthesis exercises the `historical_import` grant where backdated reference rows are needed. +- **Risks & open items**: minimum customer-notice lead time for enforced migration is an open Finance/GTM item (M5 — configurable, default 60–90 days); Subscriptions' `PlanLink` dedup contract must land jointly (the event carries the key; enforcement is theirs); upstream SKU retirement joint contract (registry) affects retiring plans whose SKU disappears first. diff --git a/gears/bss/pricing/docs/design/12-operator-efficiency.md b/gears/bss/pricing/docs/design/12-operator-efficiency.md new file mode 100644 index 000000000..3bb5b5151 --- /dev/null +++ b/gears/bss/pricing/docs/design/12-operator-efficiency.md @@ -0,0 +1,381 @@ + + + +# DESIGN — Operator Efficiency (Slice 12) + + + +- [1. Context](#1-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Scope](#15-scope) + - [1.6 Constraints & Assumptions](#16-constraints--assumptions) + - [1.7 Naming & Design-Introduced Names](#17-naming--design-introduced-names) + - [1.8 Context & Dependencies](#18-context--dependencies) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Bulk Price Import](#bulk-price-import) + - [Mass Repricing Run](#mass-repricing-run) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Plan Clone](#plan-clone) + - [Bulk Import (validate-all, commit-per-row)](#bulk-import-validate-all-commit-per-row) + - [Mass Repricing](#mass-repricing) + - [Price History and Export](#price-history-and-export) +- [4. States (CDSL)](#4-states-cdsl) + - [Bulk Operation State Machine](#bulk-operation-state-machine) +- [5. API Surface](#5-api-surface) +- [6. Data Model](#6-data-model) +- [7. Events & Alarms](#7-events--alarms) +- [8. Definitions of Done](#8-definitions-of-done) + - [Clone DoD](#clone-dod) + - [Bulk Import DoD](#bulk-import-dod) + - [Mass Repricing DoD](#mass-repricing-dod) + - [History & Export DoD](#history--export-dod) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Non-Functional Considerations](#10-non-functional-considerations) + + + +## 1. Context + +### 1.1 Overview + +This slice owns the **operator-scale surfaces** over everything Slices 1–11 authored: +**plan clone** (new ids, eligibility/lock state deliberately not copied), **bulk price +import** (all-or-nothing **validation**, optimistic **per-row commit** with a conflict +report), **mass repricing** (idempotent, re-run-safe, deduplicated events, coalesced +`CatalogVersion`s, throughput SLO), and **price history + export** for Auditor/Finance. +No new authority: every bulk path enforces the same `plan × write/publish` authz, the same +validation pipeline, and the same materiality/approval policy as single-row authoring — +bulk is authoring at scale, not a bypass. + +**Traces to**: `cpt-cf-bss-pricing-fr-plan-clone`, `cpt-cf-bss-pricing-fr-bulk-price-import`, +`cpt-cf-bss-pricing-fr-mass-repricing`, `cpt-cf-bss-pricing-fr-mutation-idempotency`, +`cpt-cf-bss-pricing-fr-price-history-export` + +### 1.2 Purpose + +Hit the PRD's ≥ 90% self-service goal for standard plan operations: annual repricing over +thousands of rows must be one safe, retryable operation that cannot silently overwrite a +concurrent manual edit, cannot flood consumers with duplicate events, and leaves the same +immutable per-row history a single edit would. + +### 1.3 Actors + +| Actor | Role in Slice | +|-------|---------------| +| `cpt-cf-bss-pricing-actor-finance-manager` | Runs clones, imports, repricing (`plan × write/publish`) | +| `cpt-cf-bss-pricing-actor-auditor` | Reads/exports price history (`plan × read`, shared with Finance — D-12) and the audit trail (`audit × read/export`, Auditor-only) | +| `cpt-cf-bss-pricing-actor-finance-reviewer` | Approves material bulk changes (one approval per batch where policy allows) | +| `cpt-cf-bss-pricing-actor-catalog-registry` | Coalesces batched publishes into few `CatalogVersion`s | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — §6.11, §7.1 (throughput/idempotency NFRs), §14 (provisional values) +- **Design**: [01-foundation.md](./01-foundation.md) — idempotency/ETag, outbox; [05-governance.md](./05-governance.md) — materiality on bulk, authz catalog; [10-advanced-primitives.md](./10-advanced-primitives.md) — clone's `discountRef` rule +- **Dependencies**: all prior slices (bulk operates over the full authored surface). + +### 1.5 Scope + +**In scope**: clone semantics (what copies, what resets); the two-phase bulk import +(validate-all → per-row optimistic commit + conflict report); mass-repricing idempotency + +event dedup + version coalescing + throughput SLO; history read/export SLO; the bulk +optimistic lock that interactive edits collide with (Foundation `fr-concurrent-edit` +counterpart). + +**Out of scope**: the operator UI (wizard/tier editor/migration UI — frontend DESIGN); +approval mechanics (Slice 5 — bulk routes through the same policy); export **format** +details beyond the SLO (implementation). + +### 1.6 Constraints & Assumptions + +Inherits Foundation C-set. Slice-12-specific: + +| # | Topic | Assumption (default) | Source | +|---|-------|----------------------|--------| +| O1 | Clone resets | `priceEligibility`/`grandfatherUntil` reset to defaults; contract locks never copy; `discountRef` copies only if it still resolves (else dropped + operator notice); `clonedFrom` recorded | PRD §6.11 | +| O2 | Import phases | Pre-commit validation is **all-or-nothing** (any invalid row blocks the batch with a per-row report); commit is **per-row** optimistic — an ETag conflict fails only that row (committed rows stand; conflicted rows listed for retry); never silent overwrite | PRD §6.11 | +| O3 | Repricing SLO | Idempotent re-run after partial failure; deduplicated events; provisional ≥ 50 rows/sec back-calculated from the tenant worst case vs the maintenance window — **ratify before Design lock** | PRD §6.11/§14 | +| O4 | Idempotency TTL | Client-key replay returns the original result within a documented TTL (value provisional); replay during an active bulk lock returns the original completed result regardless of lock state | PRD §6.11 | +| O5 | Version coalescing | A mass run MAY coalesce into one or few `CatalogVersion`s (registry batching, Foundation §4.2 step 5) | PRD §6.11 | + +### 1.7 Naming & Design-Introduced Names + +| Name | Meaning | +|------|---------| +| `PlanCloner` | Deep-copies a plan into `draft` with new ids under the O1 reset rules | +| `BulkImporter` | The two-phase import: batch validation → per-row optimistic commit + conflict report; holds the bulk optimistic lock | +| `MassRepricer` | The idempotent bulk adjustment run (row-level progress journal; event dedup; version coalescing) | +| `HistoryExporter` | Chronological immutable history read + export under Auditor/Finance filters | + +### 1.8 Context & Dependencies + +```mermaid +flowchart TB + subgraph s12["Slice 12 — Operator Efficiency"] + PC["PlanCloner"] + BI["BulkImporter"] + MR["MassRepricer"] + HE["HistoryExporter"] + end + FND["Foundation (Slice 1)
ValidationPipeline · idempotency · outbox · versioning"] + GOV["Governance (Slice 5)
materiality · approvals · audit"] + PC --> FND + BI --> FND + MR --> FND + MR --> GOV + BI --> GOV + HE --> GOV +``` + +## 2. Actor Flows (CDSL) + +### Bulk Price Import + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-flow-bulk-import` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` (`plan × write`) + +**Success Scenarios**: +- Phase 1 validates every row against the full pipeline; any invalid row blocks the batch with a per-row report; Phase 2 takes optimistic per-row locks and commits row-by-row; conflicted rows (stale ETag / concurrent manual edit) fail individually and are listed for retry — committed rows stand + +**Error Scenarios**: +- Any Phase-1 failure → `BULK_VALIDATION_FAILED` (422, per-row report; nothing committed) +- An interactive edit hitting a row under the bulk lock → conflict naming the bulk operation (Foundation `fr-concurrent-edit`) + +**Steps**: +1. [ ] - `p2` - API: POST /v1/pricing/bulk-imports (rows + client idempotency key) - `inst-bi-api` +2. [ ] - `p2` - Phase 1: all-or-nothing validation (O2); the report enumerates every violation per row - `inst-bi-validate` +3. [ ] - `p2` - A **material** batch routes through the Slice 5 policy **before any commit** — one batch approval pinning per-row content hashes (`inst-bk-approval-subset`); non-material batches proceed directly - `inst-bi-governed` +4. [ ] - `p2` - Phase 2 (post-approval): per-row optimistic commit under the bulk lock; ETag conflict fails only that row; result = `{committed[], conflicted[]}`; events emit per committed row (outbox) - `inst-bi-commit` +5. [ ] - `p2` - **RETURN** 202 (operation ref); the full per-row report is served by `GET /v1/pricing/bulk-imports/{id}`, and an idempotent replay (O4) returns the same operation ref/report; retry of the conflicted subset is a new import referencing fresh ETags - `inst-bi-return` + +### Mass Repricing Run + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-flow-mass-repricing` + +**Actor**: `cpt-cf-bss-pricing-actor-finance-manager` (`plan × write` at POST; `plan × publish` enforced at the run's publish step) + +**Success Scenarios**: +- An adjustment over N rows (e.g. +5% on a currency segment) runs idempotently: a re-trigger after partial failure resumes from the progress journal without re-applying, events deduplicate, versions coalesce (O5), throughput meets the O3 SLO + +**Error Scenarios**: +- Re-trigger with the same `run_id` while completed → returns the original result (no double apply) + +**Steps**: +1. [ ] - `p2` - API: POST /v1/pricing/repricing-runs (`run_id`, selector, adjustment) - `inst-mr-api` +2. [ ] - `p2` - Expand the selector to a frozen row set; journal per-row progress (`pending → applied | failed`) keyed `(run_id, price_id)` - `inst-mr-journal` +3. [ ] - `p2` - Apply per row through the standard versioning path (new immutable rows); the new row, its outbox record, and the journal transition `pending → applied` commit in **one transaction** (a crash re-run sees a consistent journal — no double-apply, no compounding); re-runs skip `applied` rows (idempotent, O3); a per-row validation failure marks the row `failed` (+ `failure_reason`); events carry `(run_id, price_id)` dedup keys - `inst-mr-apply` +4. [ ] - `p2` - Publishes coalesce into one/few `CatalogVersion` batches (O5); materiality evaluates once per run against the policy (any row over its own-currency threshold trips the run) - `inst-mr-coalesce` +5. [ ] - `p2` - **RETURN** 202 (run ref + progress endpoint) - `inst-mr-return` + +## 3. Processes / Business Logic (CDSL) + +### Plan Clone + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-clone` + +**Steps**: +1. [ ] - `p2` - Clone creates a new `planId` in `draft` with copied configuration and **new** price ids; phase rows are copied with **new `phase_id`s** and every copied row's `phase` axis is remapped to them (D-19); `clonedFrom` recorded; source subscriptions unaffected - `inst-cl-copy` +2. [ ] - `p2` - **Resets (O1):** `priceEligibility` → `all_subscriptions`, `grandfatherUntil` → null (eligibility must be re-decided); contract locks never copy. **`existing_grandfathered` rows are lifecycle state, not configuration** (the same O1 reasoning as windows/locks) — they are **not cloned**: the `all_subscriptions` successor row carries the going-forward price, and copying both with reset eligibility would collapse two rows onto one canonical scope key (guaranteed duplicate-scope publish failure). Superseded/closed historical rows are likewise not copied - `inst-cl-resets` +3. [ ] - `p2` - `discountRef` copies only if it still resolves to a registered instrument — else dropped with an operator notice (Slice 10 resolver reused) - `inst-cl-discount` +4. [ ] - `p2` - The clone is an ordinary draft: full pipeline + approval on its first publish (always material — first publish, G1) - `inst-cl-draft` +5. [ ] - `p2` - **Windows are not configuration:** `PriceWindow` schedules are Slice 7-owned (gear-owned, D-03) runtime state and are **never cloned** — the clone's billable rows have no coverage until the operator schedules fresh windows, and the Slice 7 coverage check blocks its publish until then (expected, surfaced in the clone response) - `inst-cl-windows` + +### Bulk Import (validate-all, commit-per-row) + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-bulk-import` + +**Steps**: +1. [ ] - `p2` - **Phase 1 — validate all-or-nothing:** every row runs the registered pipeline rules; one invalid row blocks the whole batch pre-commit with a per-row violation report (nothing partially validated sneaks through). A row whose canonical scope key holds a **pending interactive approval unit** (supersession/cutover — PRD one-pending-unit rule) fails Phase 1 **per-row**, naming the pending unit (D-35) - `inst-bk-phase1` +2. [ ] - `p2` - **Phase 2 — commit per-row optimistic:** each row commits under its own ETag; a conflict (concurrent manual edit) fails **only that row**; committed rows stand; the report lists conflicted rows for retry — silent overwrite never happens in either direction - `inst-bk-phase2` +3. [ ] - `p2` - The **bulk lock**: rows in an in-flight import are marked; an interactive edit targeting one fails with a conflict **naming the bulk operation** (Foundation `fr-concurrent-edit`) - `inst-bk-lock` +4. [ ] - `p2` - Idempotency (O4): the import's client key replays to the original report, including during/after the lock window - `inst-bk-idem` +5. [ ] - `p2` - **Approval covers the set, commit may shrink it:** the batch approval (Slice 5) pins **per-row content hashes**; Phase 2 conflicts shrink the committed subset — legal, because committed ⊆ approved and nothing outside the pin ever publishes. A retry import of conflicted rows whose content hash is **unchanged** reuses the original approval; any changed row starts a fresh approval cycle. While the batch approval is `submitted`, it **counts as the pending approval unit for every contained scope key** (D-35): an interactive supersession/cutover submit on one of those keys returns 409 (`PENDING_CHANGE_UNIT_EXISTS`, naming the bulk operation) — symmetric with Phase 1's per-row check - `inst-bk-approval-subset` + +### Mass Repricing + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-mass-repricing` + +**Steps**: +1. [ ] - `p2` - The run journal `(run_id, price_id, state)` is the idempotency spine: re-runs after partial failure resume, never re-apply (O3) - `inst-mp-journal` +1b. [ ] - `p1` - **Pending-unit conflicts (D-35):** a selector row whose scope key holds a pending interactive unit fails **per-row** (journal `failed`, names the unit); the run's batch approval pins its keys exactly like bulk import - `inst-mp-pending` +1a. [ ] - `p1` - **Grandfathered rows are excluded:** repricing selectors structurally exclude `existing_grandfathered` rows — they are immutable in price (Foundation §4.3); an explicit attempt to include one fails that row with a per-row validation error, never a silent skip and never a reprice - `inst-mp-grandfathered` +2. [ ] - `p2` - Every applied row is a **standard** versioned change (new immutable row via the Foundation path — bulk never mutates in place); events carry dedup keys so consumers de-duplicate on redelivery + re-run - `inst-mp-standard` +3. [ ] - `p2` - Version coalescing (O5): the run requests batched addressability; `pricingSnapshotRef` pins whatever committed batch the registry emits - `inst-mp-coalesce` +4. [ ] - `p2` - Throughput: provisional ≥ 50 rows/sec, to be back-calculated from the tenant worst-case row count against the agreed maintenance window and **ratified before Design lock** (O3) - `inst-mp-slo` + +### Price History and Export + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-algo-history-export` + +**Steps**: +1. [ ] - `p2` - Chronological immutable price-history records (the append-only `pricing_price` rows) with actor and effective dates, under `plan × read` (D-12 — history is plan/price data, Finance-readable by construction; the separate Slice-5 audit trail stays `audit × read`, Auditor-only) - `inst-he-read` +2. [ ] - `p2` - Export (`plan × read`, D-12) within p95 ≤ 5s per 100 records - `inst-he-export` +3. [ ] - `p2` - History is a **read** over existing append-only structures — this slice adds no new history store (the Foundation's immutability IS the history) - `inst-he-nostore` + +## 4. States (CDSL) + +### Bulk Operation State Machine + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-state-bulk-operation` + +**States**: validating, validation_failed, committing, completed, completed_with_conflicts +**Initial State**: validating (Phase 1) + +**Transitions**: +1. [ ] - `p2` - **FROM** validating **TO** validation_failed **WHEN** any row fails Phase 1 (nothing committed; per-row report) - `inst-bs-fail` +2. [ ] - `p2` - **FROM** validating **TO** awaiting_approval **WHEN** all rows pass and the batch is material (the Slice 5 batch approval pins per-row hashes; rows are **not** locked while awaiting — interactive edits surface later as per-row ETag conflicts, legal since committed ⊆ approved) - `inst-bs-approval` +3. [ ] - `p2` - **FROM** validating **TO** committing **WHEN** all rows pass and the batch is non-material; **FROM** awaiting_approval **TO** committing **WHEN** approved — the bulk lock takes effect on entry to `committing` - `inst-bs-commit` +4. [ ] - `p2` - **FROM** committing **TO** completed / completed_with_conflicts **WHEN** every row committed / some rows ETag-conflicted (listed for retry; lock released either way) - `inst-bs-done` +5. [ ] - `p2` - **FROM** committing **TO** completed_with_conflicts **WHEN** the operator **aborts** a stalled run (D-37): uncommitted rows reported `not-attempted`, lock cleared; crash recovery is lease takeover + journal re-drive, not abort - `inst-bs-abort` + +## 5. API Surface + +| Method | Path | Purpose | Idempotency | AuthZ | +|--------|------|---------|-------------|-------| +| `POST` | `/v1/pricing/plans/{planId}/clone` | Clone into a new draft plan | client key | `plan × write` | +| `POST` | `/v1/pricing/bulk-imports` | Two-phase bulk price import | client key (O4) | `plan × write` | +| `GET` | `/v1/pricing/bulk-imports/{id}` | Batch report (per-row outcomes) | — | `plan × read` | +| `POST` | `/v1/pricing/repricing-runs` | Idempotent mass adjustment | `run_id` | `plan × write` | +| `GET` | `/v1/pricing/repricing-runs/{id}` | Run progress / result | — | `plan × read` | +| `GET` | `/v1/pricing/history` | Immutable price history (filters) | — | `plan × read` (D-12) | +| `POST` | `/v1/pricing/history/export` | History export (SLO-bound) | client key | `plan × read` (D-12) | + +**Problem responses (RFC 9457):** `BULK_VALIDATION_FAILED` (422, per-row), +`BULK_ROW_CONFLICT` (reported per row in the operation report), `RUN_SELECTOR_EMPTY` (422), +`CLONE_SOURCE_NOT_FOUND` (404). Interactive-vs-bulk conflicts surface as the Foundation's +concurrent-edit conflict naming the bulk operation. + +## 6. Data Model + +Slice-owned tables (`pricing_` prefix per Foundation §3.7): + +**`pricing_bulk_operation`** (PK `operation_id`): `kind` (`import | repricing`), `state`, +`client_key` (idempotency, O4), `report` (`jsonb` — per-row outcomes), `submitted_by`, +timestamps. + +**`pricing_repricing_journal`** (PK `(run_id, price_id)`): `state` +(`pending | applied | failed`), `failure_reason` (nullable), `applied_price_id` (the new row +created), `applied_at` — the idempotency spine (O3). A run is **complete** when no `pending` +rows remain; `failed` rows are listed on the run report and are retryable only via a +corrected **new** run. + +**Bulk lock** — a marker on `pricing_price` rows (`bulk_operation_id` nullable column or a +lock side-table, implementation choice) that the Foundation's concurrent-edit check reads to +name the conflicting operation. **Release path (D-37):** the bulk runner holds a +**coordination lease** (the library named in DESIGN §3.4); on crash, lease takeover +**re-drives** Phase 2 from the journal/report (idempotent); additionally an operator +**abort** (`POST /v1/pricing/bulk-imports/{id}:abort`, `plan × write`) transitions +`committing → completed_with_conflicts` — uncommitted rows reported as `not-attempted`, the +lock cleared. A crashed import can never freeze interactive authoring indefinitely. + +Clone writes ordinary `pricing_plan`/`pricing_price` draft rows (+ `cloned_from` on the +plan). History/export reads existing append-only structures — no new store. + +## 7. Events & Alarms + +No new frozen event names: bulk paths emit the standard `PriceCreated`/`PriceUpdated` per +committed row (dedup keys `(run_id | operation_id, price_id)`) and `PlanPublished` **per +affected plan publish** (coalesced per O5 — never per row). +Alarms: `pricing.bulk.run_stalled` (Warn — a run without progress past a horizon), +`pricing.bulk.conflict_rate_high` (Info — a batch with an unusually high conflicted-row +share, signalling concurrent-editing contention). + +## 8. Definitions of Done + +### Clone DoD + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-clone` + +Clone **MUST** produce a new draft `planId` with new price ids and `clonedFrom`, resetting +eligibility state (`priceEligibility`/`grandfatherUntil`), never copying contract locks, +`existing_grandfathered` rows, superseded/closed historical rows, or `PriceWindow` schedules +(the clone's publish stays coverage-blocked until fresh windows are scheduled), copying +`discountRef` only when it still resolves (else dropped + notice), and leaving +source subscriptions untouched. + +**Implements**: `cpt-cf-bss-pricing-algo-clone` + +**Touches**: +- API: `POST /v1/pricing/plans/{planId}/clone` +- DB: `pricing_plan.cloned_from` +- Entities: `PlanCloner` + +### Bulk Import DoD + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-bulk-import` + +Bulk import **MUST** validate all-or-nothing pre-commit (per-row report), route a material +batch through the Slice 5 approval **before** commit (per-row hash pin; committed ⊆ approved; +a retry of unchanged conflicted rows reuses the original approval, a changed row starts a +fresh one), commit per-row under optimistic locks (a conflict fails only that row; committed +rows stand; conflicted rows listed), never silently overwrite in either direction, and replay +idempotently to the original report. + +**Implements**: `cpt-cf-bss-pricing-flow-bulk-import`, `cpt-cf-bss-pricing-algo-bulk-import`, `cpt-cf-bss-pricing-state-bulk-operation` + +**Touches**: +- API: `POST/GET /v1/pricing/bulk-imports*` +- DB: `pricing_bulk_operation` +- Entities: `BulkImporter` + +### Mass Repricing DoD + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-mass-repricing` + +A mass adjustment **MUST** be re-run-safe via the per-row journal (no re-apply; row + outbox ++ journal flip commit in one transaction), structurally exclude `existing_grandfathered` rows +(an explicit inclusion fails that row per-row, never a silent skip), emit deduplicated +events, coalesce versions per the registry batching, route materiality once per run, and meet +the (to-be-ratified) throughput SLO. + +**Implements**: `cpt-cf-bss-pricing-flow-mass-repricing`, `cpt-cf-bss-pricing-algo-mass-repricing` + +**Touches**: +- API: `POST/GET /v1/pricing/repricing-runs*` +- DB: `pricing_repricing_journal` +- Entities: `MassRepricer` + +### History & Export DoD + +- [ ] `p2` - **ID**: `cpt-cf-bss-pricing-dod-history-export` + +The system **MUST** return chronological immutable price history (effective dates + amounts; +actor detail lives in the Auditor-only audit trail) under `plan × read` — serving Finance and +Auditor alike (D-12) — and export within p95 ≤ 5s per 100 records, reading existing +append-only structures only. + +**Implements**: `cpt-cf-bss-pricing-algo-history-export` + +**Touches**: +- API: `GET /v1/pricing/history`, `POST /v1/pricing/history/export` +- DB: (reads `pricing_price` history + `pricing_audit_log`) +- Entities: `HistoryExporter` + +## 9. Acceptance Criteria + +Unit: + +- [ ] Clone reset matrix (eligibility/grandfather/locks/discountRef-dangling; `existing_grandfathered` and superseded rows not copied); Phase-1 single-bad-row blocks the batch; Phase-2 conflict isolation (row N conflicts, N±1 commit); journal resume skips `applied`; an explicit grandfathered inclusion fails that row per-row; idempotency replay during an active lock returns the original result (O4) + +Integration (testcontainers): + +- [ ] A 1k-row import with one invalid row commits nothing and reports the row; fixed, it commits with 3 concurrent-edit conflicts isolated and listed +- [ ] An interactive PATCH on a bulk-locked row fails naming the bulk operation +- [ ] A repricing run killed mid-way re-runs to completion without double-applying any row (journal-verified); events deduplicate on the consumer side by `(run_id, price_id)` +- [ ] A material bulk batch blocks in `awaiting_approval` until the batch approval lands; a retry of unchanged conflicted rows publishes without a new approval, a changed row requires one +- [ ] A clone's publish is blocked by window coverage until fresh windows are scheduled +- [ ] History export of 100 records within the SLO; entries carry actor + effective dates + +NFR verification: + +- [ ] Throughput load test against the ratified O3 value over the tenant worst-case row count + +## 10. Non-Functional Considerations + +- **Performance**: Phase-1 validation parallelizes per row (shared-nothing rules); Phase-2 commit is row-transactional; the repricing journal adds one indexed write per row. The O3 throughput value and the plan/tier caps are **provisional NFRs — ratify before Design lock** ([`../PRD.md`](../PRD.md) §14). +- **Observability**: `pricing_bulk_rows_total{outcome}`, `pricing_repricing_rows_per_second`, `pricing_bulk_conflicts_total`, run-progress gauges. +- **Security & AuthZ**: bulk carries **no new authority** — `plan × write/publish` + the same materiality/approval policy; price history/export is `plan × read` (D-12), while the audit trail stays `audit × read/export`, Auditor-only (Slice 5 catalog). +- **Risks & open items**: idempotency-key TTL and the throughput SLO are provisional (O3/O4); a mass run's coalesced `CatalogVersion` depends on the registry's batching-delay SLO (open with Registry) — a slow batch delays snapshot pinning for the whole run. **Bulk window operations**: an N-row repricing implies N supersession window open/close operations — since the window consolidation (D-03) these are local writes to the gear-owned `pricing_price_window` store inside the per-row transactions, so their throughput is part of this slice's own O3 sizing (no cross-component contract). diff --git a/gears/bss/pricing/docs/design/README.md b/gears/bss/pricing/docs/design/README.md new file mode 100644 index 000000000..99a7ecc91 --- /dev/null +++ b/gears/bss/pricing/docs/design/README.md @@ -0,0 +1,71 @@ + + + +# Plan & Price Modeling — Design Set + + + +- [Slice documents](#slice-documents) +- [Slice map (PRD §6 ↔ implementation phase)](#slice-map-prd-6--implementation-phase) + + + +This folder holds the Plan & Price Modeling technical design as a **set of slice designs**: a +shared **Catalog Foundation** ([`01-foundation.md`](./01-foundation.md)) plus per-capability +handler designs. Every slice **publishes through** the Foundation — the `Plan`/`Price` entity +model, the canonical scope key, the draft→publish state machine, the fail-closed validation +pipeline, append-only versioning/supersession, the read-model projection + `pricingSnapshotRef` +contract, and the frozen event fan-out. The Foundation owns no capability policy (it does not +know what a billing cycle or model kind is); each slice is a handler that authors draft state, +registers its validation rules and read-model fields, and calls the Foundation publish API. + +**The canonical index for this set — architecture overview, the phased slice map, dependency +order, cross-cutting normative statements, the ADR index, and traceability — is +[`../DESIGN.md`](../DESIGN.md).** Requirements (WHAT/WHY) live in [`../PRD.md`](../PRD.md); +decision rationale in [`../ADR/`](../ADR/). + +## Slice documents + +- [`01-foundation.md`](./01-foundation.md) — **shared engine**: `Plan`/`Price` model, canonical scope key, draft→publish state machine, fail-closed validation pipeline, append-only history + versioning/supersession, read-model projection + `pricingSnapshotRef`, event fan-out + `CatalogVersion` request, tenant isolation, ISO 4217 money, idempotency/ETag. Carries the catalog-wide normative statements (§4). +- [`02-plan-definition.md`](./02-plan-definition.md) — billing cycles, custom frequency, per-seat quantity provenance (`quantitySource` persisted/validated in Slice 3), one-time-setup row, mandatory `PlanTier`, meter injectivity, add-on rules, phases + `convertsToPhaseId`, billing descriptors (PRD §6.1, §6.3) +- [`03-price-structure.md`](./03-price-structure.md) — explicit `modelKind`, tier-band validation, `package` pricing, evaluation-policy placement, joint golden-fixture conformance gate (PRD §6.2) +- [`04-currency-tax.md`](./04-currency-tax.md) — per-`(currency, region)` rows, region/brand taxonomies, tax-display basis + `not_sellable_ga` gate, single-currency-per-invoice binding, base-price preview (PRD §6.4) +- [`05-governance.md`](./05-governance.md) — materiality + two-person rule, per-currency threshold policy, RBAC deny-by-default + preview/backdating grants, tenant/region isolation, audit trail + retention (PRD §6.7 approval, §6.12) +- [`06-consumer-contracts.md`](./06-consumer-contracts.md) — proration input contract (canonical `prorationBasis` enum), `billingTiming`, entitlement grant set, plan-change contract, rating compatibility (PRD §6.9) +- [`07-pricewindow-linkage.md`](./07-pricewindow-linkage.md) — `PriceWindow` ownership (store, state machine, activation job, `PriceWindow*` events — consolidated per D-03), window coverage + future-gap, sellability surface (joint gate), grandfathering eligibility + atomic cutover (PRD §6.5) +- [`08-bundles.md`](./08-bundles.md) — bundle price basis (`sum_of_parts` via component `planId`s / `own_price`), currency + frequency coverage, rev-share reconciliation, itemization (PRD §6.3 bundle) +- [`09-price-overlays.md`](./09-price-overlays.md) — `PriceOverlay` authoring/validation (scope, adjustment, explicit precedence, tax basis) + `customerGroup` taxonomy, effective-dated audited membership, resolved-group freezing (PRD §6.6) +- [`10-advanced-primitives.md`](./10-advanced-primitives.md) — reserved capacity (same-row attributes), prepaid grant (GA-gated), derived meter formula-as-data, `discountRef` hook, typed `minQtyThreshold` (PRD §6.10) +- [`11-lifecycle.md`](./11-lifecycle.md) — retirement (window-cancellation trigger + operator warning), scheduled migration + `PlanLink` (idempotent, cancellable), safety deltas + contract-lock exclusion, `migrated-origin` snapshot synthesis (PRD §6.8) +- [`12-operator-efficiency.md`](./12-operator-efficiency.md) — clone (eligibility resets), bulk import (validate-all / commit-per-row), mass repricing (journaled idempotency, event dedup, version coalescing), history + export (PRD §6.11) + +The design set is **complete**: all 12 slices are authored. See +[`../DESIGN.md` §1.3](../DESIGN.md#13-architecture-layers) for phases and dependencies. + +## Slice map (PRD §6 ↔ implementation phase) + +The numeric prefix is **implementation order**, not the PRD §6 subsection number — the two +axes deliberately do not line up (a slice is scoped by PRD decomposition but built when its +dependencies exist). + +| Doc | PRD §6 | Phase | Depends on | +|-----|--------|-------|------------| +| `01-foundation` | 6.2/6.7 core, §17.4/17.5 | 0/1 | — | +| `02-plan-definition` | 6.1, 6.3 | 1 | 01 | +| `03-price-structure` | 6.2 | 1 | 01, 02 | +| `04-currency-tax` | 6.4 | 1/2 | 01, 02 | +| `05-governance` | 6.7, 6.12 | 1/2 | 01 | +| `06-consumer-contracts` | 6.9 | 2 | 01, 02, 03 | +| `07-pricewindow-linkage` | 6.5 | 2 | 01, 02, 03, 05 | +| `08-bundles` | 6.3 (bundle) | 2/3 | 02, 03, 04 | +| `09-price-overlays` | 6.6 | 3 | 01, 04, 05 | +| `10-advanced-primitives` | 6.10 | 3 | 02, 03, 05 | +| `11-lifecycle` | 6.8 | 3/4 | 05, 07 | +| `12-operator-efficiency` | 6.11 | 4 | all | + +05-governance additionally gates **every** slice's publish path (approval + authz); the +column lists it only where a slice depends on 05 beyond that universal gate (cutover +approval unit, membership materiality, grant-price materiality, backdating grant). + +See [`../DESIGN.md` §1.3](../DESIGN.md#13-architecture-layers) for the dependency graph and +phase rationale. diff --git a/gears/bss/products/docs/PRD.md b/gears/bss/products/docs/PRD.md new file mode 100644 index 000000000..95a481176 --- /dev/null +++ b/gears/bss/products/docs/PRD.md @@ -0,0 +1,1663 @@ +--- +refs: + - bss/manifest/vz-arch-manifest-bss-only.md + - bss/prd/PRD-billing-ledger-balances-202604041200 + - bss/prd/PRD-billing-module-202601120119 + - bss/prd/PRD-billing-system-202601120119 + - bss/prd/PRD-contracts-agreements-202601120119 + - bss/prd/PRD-metering-pricing-module-202601120119 + - bss/prd/PRD-plan-price-modeling-202605281200 + - bss/prd/PRD-product-catalog-marketplace-202601120119 + - bss/prd/PRD-rating-engine-202604031200 + - bss/prd/PRD-subscriptions-entitlements-202601120119 + - bss/prd/PRD-subscriptions-lifecycle-202604021200 + - bss/prd/PRD-tariffs-pricing-logic-202604011200 +--- + +# PRD — Product & SKU Management + +> **Provenance (2026-07-16):** vendored from `constructorfabric/gears-rust` PR **#4177** +> (`add-product-sku-prd` @ `6d3aab4`, author Corw1n-of-Amber) — this branch is the canonical home +> (upstream detach decision, 2026-07-15/16); no back-port or drift-tracking obligation. +> **Local changes applied at vendoring:** gear renamed **`product-sku` → `products`** (2026-07-16, +> incl. the ID prefix `cpt-cf-bss-product-sku-*` → `cpt-cf-bss-products-*`), and the RG2 fix in `cpt-cf-bss-products-fr-metering-unit-declaration` +> (unit ≠ dimension; the separate-SKUs mandate for multi-dimension usage replaced with the +> plan-price-owned dimension-set model — rating `SEAMS.md` §I RG2). **Known localization debt +> (tracked as rating SEAMS §I RG3):** pre-consolidation names (`PRD-tariffs-pricing-logic`, +> `PRD-rating-engine`, actor `…-actor-tariffs` — post ADR-0002 both map to the one **rating** gear, +> `gears/bss/rating/docs/PRD.md`) and `refs` front-matter paths in the legacy `docs/bss/prd/…` +> layout (kept verbatim as provenance). **RG3 reconciled 2026-07-16** at the first substantive +> edit: actor `…-actor-tariffs` merged into `…-actor-rating`; §2.1 delegations and the §17 +> reference table localized to `gears/bss/rating/docs/PRD.md` / `gears/bss/subscriptions/docs/PRD.md`. +> **Further local change (D-46, 2026-07-16):** the `sellable` flag FR (`fr-sku-sellable`) — +> offering eligibility, enforced as pricing sellability-gate predicate 6. + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Architecture Alignment](#2-architecture-alignment) + - [2.1 Catalog Decomposition and Registry Boundary](#21-catalog-decomposition-and-registry-boundary) + - [2.2 Predecessor PRDs and Scope Migration](#22-predecessor-prds-and-scope-migration) +- [3. Actors](#3-actors) + - [3.1 Human Actors](#31-human-actors) + - [3.2 System Actors](#32-system-actors) +- [4. Operational Concept & Environment](#4-operational-concept--environment) + - [4.1 Module-Specific Environment Constraints](#41-module-specific-environment-constraints) +- [5. Scope](#5-scope) + - [5.1 In Scope](#51-in-scope) + - [5.2 Out of Scope](#52-out-of-scope) +- [6. Functional Requirements](#6-functional-requirements) + - [6.1 Identifiers & Integrity](#61-identifiers--integrity) + - [6.2 Product & Taxonomy Definition](#62-product--taxonomy-definition) + - [6.3 SKU Definition & Classification](#63-sku-definition--classification) + - [6.4 Attributes & Localization](#64-attributes--localization) + - [6.5 Versioning, Lifecycle & Deprecation](#65-versioning-lifecycle--deprecation) + - [6.6 Catalog Versioning & Snapshots](#66-catalog-versioning--snapshots) + - [6.7 Approval, Publishing & Eventing](#67-approval-publishing--eventing) + - [6.8 Multi-Tenancy & Read Models](#68-multi-tenancy--read-models) + - [6.9 Bulk Operations](#69-bulk-operations) + - [6.10 Cloning](#610-cloning) + - [6.11 Data Retention & Erasure](#611-data-retention--erasure) + - [6.12 Cross-PRD Consistency](#612-cross-prd-consistency) + - [6.13 Operational Resilience & Concurrency](#613-operational-resilience--concurrency) +- [7. Non-Functional Requirements](#7-non-functional-requirements) + - [7.1 NFR Inclusions](#71-nfr-inclusions) + - [7.2 NFR Exclusions](#72-nfr-exclusions) +- [8. Five Quality Vectors Analysis](#8-five-quality-vectors-analysis) +- [9. Public Library Interfaces](#9-public-library-interfaces) + - [9.1 Public API Surface](#91-public-api-surface) + - [9.2 External Integration Contracts](#92-external-integration-contracts) +- [10. Use Cases](#10-use-cases) +- [11. User Interaction and Design](#11-user-interaction-and-design) +- [12. Acceptance Criteria](#12-acceptance-criteria) + - [Identifiers & Integrity](#identifiers--integrity) + - [Product & Taxonomy Definition](#product--taxonomy-definition) + - [SKU Definition & Classification](#sku-definition--classification) + - [Attributes & Localization](#attributes--localization) + - [Versioning, Lifecycle & Deprecation](#versioning-lifecycle--deprecation) + - [Catalog Versioning & Snapshots](#catalog-versioning--snapshots) + - [Approval, Publishing & Eventing](#approval-publishing--eventing) + - [Multi-Tenancy & Read Models](#multi-tenancy--read-models) + - [Bulk Operations](#bulk-operations) + - [Cloning](#cloning) + - [Data Retention & Erasure](#data-retention--erasure) + - [Cross-PRD Consistency](#cross-prd-consistency) + - [Error & Negative Paths](#error--negative-paths) + - [Operational Resilience & Concurrency](#operational-resilience--concurrency) + - [Non-Functional Requirements (Show-Stoppers)](#non-functional-requirements-show-stoppers) +- [13. Dependencies](#13-dependencies) +- [14. Assumptions](#14-assumptions) +- [15. Open Questions](#15-open-questions) +- [16. Risks](#16-risks) +- [17. Reference Materials](#17-reference-materials) + - [17.1 Configurable-Policy Interim Defaults](#171-configurable-policy-interim-defaults) + - [17.2 Monetization-Model Traceability](#172-monetization-model-traceability) + + + +## 1. Overview + +### 1.1 Purpose + +**Product & SKU Management** is the authoritative, multi-tenant **catalog registry** for VHP BSS: the System of Record for *what can be sold, how it is described, classified, versioned, and published*. It owns Products, SKUs, categories/taxonomy, attributes/localization, and immutable catalog versions, with **financial-grade governance** (approval-gated publishing, immutable audit, deterministic snapshots) so that Plan & Price Modeling, Subscriptions, Contracts, Tariffs/Rating, Billing, Marketplace, and Presentation build on **stable, versioned, reproducible** catalog references. + +It owns the **registry** half of BSS manifest §4.1 and stops at the SKU (including the `bundle` type flag and the metering-unit declaration). All commercial-pricing concerns are delegated by reference to the sibling decomposition PRDs (§2.1). + +### 1.2 Background / Problem Statement + +BSS must monetize diverse offerings (IaaS, PaaS, SaaS, marketplace services) across a multi-tenant, brand/region-scoped hierarchy. Without a single authoritative catalog registry, plan/price authoring, subscriptions, contracts, rating, and billing bind to mutable, non-reproducible product state — breaking posted invoices, active contracts, and in-flight subscriptions when the catalog changes, and leaving governance (who approved what, when) unauditable. + +This PRD carves the **registry** scope out of the combined predecessor (`PRD-product-catalog-marketplace-202601120119`), completing the §4.1 decomposition already begun by Tariffs and Plan & Price Modeling. It fixes lifecycle/versioning semantics (draft → published [↔ deprecated] → retired, immutable history), a catalog publish contract (approval-gated, idempotent, event-fanned-out), a catalog-wide immutable `CatalogVersion` snapshot, and a stable SKU contract (identity, type, `PlanTier`, metering-unit declaration) that downstream modules can assume without re-validation. + +### 1.3 Goals (Business Outcomes) + +- **Flexibility / time-to-market**: Product Managers self-serve Product, SKU, category, and attribute changes across offering types without engineering involvement. +- **Stable monetization foundation**: every published SKU exposes a stable identifier, type, `PlanTier` classification, and metering-unit declaration, so plan/price authoring and rating bind to a fixed reference. +- **Auditable governance**: two-person approval for material catalog changes, immutable version history, and a complete CloudEvents audit trail satisfy financial and regulatory controls. +- **Safe evolution**: backward-compatible schema evolution and immutable `CatalogVersion` snapshots let the catalog change without breaking posted invoices, active contracts, or in-flight subscriptions. +- **Single source of truth**: one authoritative registry feeds partner/brand/region-scoped offerings, marketplace listings, and contract quotes. + +> **Note**: The registry-vs-commercial boundary is stated canonically in §2.1. Where requirements or acceptance criteria touch commercial concerns, they define only the **registry-side contract** and reference the owning PRD. + +### 1.4 Glossary + +| **Term** | **Definition** | +|----------|----------------| +| **Catalog (registry)** | The authoritative registry of products/services/bundles/SKUs, categories, and localized attributes, and the catalog-wide version/publish mechanism (manifest §4.1). SoR: BSS. Defines *what can be sold and how it is described, classified, and published* — not how it is priced. | +| **Product** | A sellable or describable offering record with a name, **one required primary category plus optional secondary categories**, lifecycle state, brand/region scope, and version. The top of the catalog hierarchy. Identified by a system-generated `productId`. | +| **SKU (Stock Keeping Unit)** | A uniquely identifiable variant of a Product, typed as `product`, `service`, or `bundle`, optionally carrying a **metering-unit declaration** (for usage products) and stable accounting codes (`taxCategory`, `glCode`). A SKU has two identifiers: a system-generated immutable `skuId` and an operator-supplied human-readable `skuCode`. A SKU carries its own brand/region scope, **contained within its parent Product's scope**; the SKU→Product link is immutable after first publish. | +| **Usage SKU** | Definition, not detection: a SKU that **carries a metering-unit declaration**. There is no separate "is-usage" flag — declaring a metering unit **is** what makes a SKU a usage SKU. "A usage SKU missing its declaration" is not a detectable registry state; usage-completeness is enforced at the plan-price seam, never at registry publish. | +| **Sellable** | Per-SKU offering-eligibility flag (`sellable`, default `true`; D-46). `sellable = false` = **composition/metering-only**: the SKU publishes normally, MAY be referenced as a bundle/plan component and MAY carry a metering-unit declaration, but MUST NOT be offered **standalone** (pricing sellability-gate predicate 6). Distinct from lifecycle (`published` = *referenceable*) and from per-market GA gates (`not_sellable_ga`). The migration cover for technical/component SKUs of existing catalogs. | +| **Identifier** | The registry distinguishes **system identity** from **human/business code**. `productId`/`skuId` are server-generated immutable UUIDs. `skuCode` is operator-supplied, fixed-format, tenant-unique, immutable after first publish. Products MAY carry an optional `productCode` under the same reservation rules. Downstream consumers bind to `skuId`; humans/external catalogs reference `skuCode`/`productCode`. | +| **Bundle (SKU type)** | A SKU whose `type = bundle`. This PRD owns only the **type flag and identity**; the bundle's commercial composition (included SKUs, constraints, revenue share, invoice itemization) is authored in plan-price. A published bundle is commercially incomplete until composed. | +| **Category** | A node in the catalog taxonomy for browse, search, curation, and marketplace listing classification; supports hierarchy. | +| **Attribute** | A **governed** (defined, typed, optionally localized) key/value descriptor attached to a Product or SKU with brand/region visibility, managed via attribute **definitions**. Contrast the ungoverned **Metadata map**. | +| **Metadata map** | An **ungoverned**, per-entity free-form key/value channel for machine metadata (external ids, sync markers, migration tags): tenant-scoped, size-bounded, non-localized, excluded from read-model search, still PII-prohibited, captured in `CatalogVersion` snapshots. | +| **Brand** | A commercial/presentation identity within a tenant under which Products/SKUs/attributes are scoped for visibility, isolation, and localized display. A **visibility/legal scope, not a pricing dimension**. | +| **Region** | A geography/jurisdiction scope on Product/SKU/Attribute governing **visibility, legal availability, and localization fallback** — **never** pricing (currency/price-region/FX are plan-price/Tariffs). Drives name-uniqueness and read-model scoping. The region-set algebra is a pre-approval gate (§15). | +| **PlanTier** | Mandatory classification carried on SKUs/Plans (manifest §4.1) consumed by Subscriptions, `SlaPolicy`, and quota/entitlement policies. This PRD owns the **PlanTier taxonomy and the SKU-level value**; plan-price enforces presence at **plan** publish. Distinct from **OrgTier** (a partner commercial standing that never changes tenant topology). | +| **Metering-unit declaration** | The unit identity (e.g. vCPU-hours, GB-storage) declared on a usage SKU. This PRD owns the **declaration and its validation**; usage collection is OSS metering, plan-level meter binding is plan-price, and rating is Rating. | +| **Lifecycle state** | The Product/SKU state machine: `draft → published [↔ deprecated] → retired`, plus `draft → discarded` for never-published entities. `deprecated` is a governed sub-state of `published` (referenceable by existing consumers, closed to new adoption). `retired` is terminal (revival only via clone). `discarded` is terminal for an abandoned never-published draft (releases the `skuCode` reservation, audited, emits a discard event). | +| **Deprecation** | A governed marking of a `published` SKU that blocks **new adoption** while existing references continue, ahead of eventual retirement; modeled as the `deprecated` sub-state. | +| **Retirement / EOL** | Retirement runs as a **scheduled transition**: at initiation the entity is forced into `deprecated` (new adoption blocked immediately, still browsable) for the lead-time window (≥ 30 days interim), then flips to `retired` at `effectiveAt`. **EOL** is the optional stronger variant that additionally sets a `mustMigrateBy` date; the registry guarantees only the event + lead-time contract, while live-subscription migration is owned by subscriptions-lifecycle. | +| **Revision vs published version** | Two counters. The **internal revision** increments on every save (incl. draft edits) for optimistic concurrency and audit. The **published version** increments only on publish and is what downstream consumers and `CatalogVersion` reference. Draft churn MUST NOT inflate the published version. | +| **CatalogVersion** | An immutable, checksummed, **full** published snapshot of the catalog at publish time (monotonic `catalogVersionId`), enumerating the published Product/SKU/Category/Attribute set and their published versions. Re-resolving a `catalogVersionId` MUST always yield a byte-identical checksum. plan-price/Contracts/Billing freeze their own content keyed to `catalogVersionId`. One **component** of a downstream `pricingSnapshotRef` (defined in Tariffs); not equal to it. | +| **Material change / Materiality threshold** | A change is material when it touches the enumerated material-field set (canonically defined in the Materiality-gated-publish FR/AC) or exceeds a configured count of affected entities. The threshold is a typed, configurable policy with a default; material changes trigger the two-person rule. | +| **Recognized-unit set** | The configured set of metering units a usage SKU may declare. Owner and add-unit approval path are an owned dependency (§15); the registry validates declarations against the configured set. | +| **Two-person rule** | A multi-approver control requiring at least two distinct approvers (each distinct from the author) for material/above-threshold catalog changes before publication. | +| **Read model** | Cache-first, query-optimized projection of published catalog content for high-throughput browse/search; converges within a bounded window of the write model. | +| **Active-reference count** | Whether a SKU has live downstream references (plans, subscriptions, contracts). Derived as a **3-state predicate** from the `SkuReferenceCount` signal: **fresh-zero ⇒ unreferenced** (enables correction), **fresh > 0 ⇒ referenced**, **stale/never-received ⇒ conservatively referenced**. | +| **`SkuReferenceCount` signal** | A named, owned integration contract by which downstream modules (Subscriptions, Contracts, plan-price) publish reference liveness via a **per-producer watermark** ("as of `T`, my complete live-reference set is {…}"). Absence of a `skuId` under a fresh watermark ⇒ zero for that producer. `referenced` is a boolean OR across producers; the registry never sums across producers. A stale watermark is conservatively referenced + alerted; never-received is conservative but flagged distinctly. | +| **freezeComplete** | A per-`CatalogVersion` flag indicating that all registered freeze-participants (plan-price, Contracts, Billing) have acknowledged freezing their referenced content for that `catalogVersionId`. Resolution for posted/contractual use is rejected until `freezeComplete`, with a bounded timeout that fails closed. | +| **Freeze-participant set** | The configured list of modules that MUST acknowledge a freeze for a `CatalogVersion`. Membership is governed (two-person) and snapshotted into each `catalogVersionId`. | +| **Grandfathering** | Continuation of an existing reference on its frozen snapshot after the underlying SKU is deprecated/retired. The registry guarantees the snapshot is never mutated; eligibility policy is owned by plan-price / subscriptions-lifecycle. | +| **`compositionPending`** | A registry flag on a `bundle` SKU published-with-override while still uncomposed. While `true` the SKU is not-yet-adoptable for new references; cleared by a plan-price composition signal and emitted as `BundleCompositionCompleted`. | +| **OrgTier** | A partner's commercial standing (a partner commercial projection that MUST NOT change tenant topology). Distinct from `PlanTier`. | + +## 2. Architecture Alignment + +| **Field** | **Value** | +|-----------|----------| +| **Applicable Manifest(s)** | BSS | +| **Manifest Chapters** | §4.1 Product and Service Catalog (primary — catalog **registry**: Product, SKU, Category, Attribute, CatalogVersion, lifecycle, approvals, publish); §4.4 Billing and Invoicing (posting/snapshot immutability); §4.3 Subscriptions, §4.2 Rating, §4.6 Contracts, §4.8 Marketplace (consumers of published SKUs); §2.1.3 Multi-tenant semantics; §7.2 Event governance (CloudEvents 1.0) | + +> **Normative alignment**: This PRD owns the **catalog registry** half of BSS §4.1 — the SoR for **Product, SKU, Category, Attribute, and CatalogVersion**, plus their authoring, versioning, lifecycle, taxonomy, localization, governance, publishing, and catalog-wide snapshotting. It MUST NOT contradict the sibling decomposition PRDs and MUST delegate, by reference, every commercial-pricing concern. + +### 2.1 Catalog Decomposition and Registry Boundary + +The combined Catalog (§4.1) capability is split across complementary PRDs (registry **plus** plan-price together realize "Catalog"). This PRD implements the **registry** half and is authoritative ONLY for the registry concerns above. The following are owned elsewhere and MUST NOT be re-specified here: + +- **Plan, Price, PriceWindow, PriceList, Bundle composition, add-on rules, billing descriptors (invoice line template / tax category / GL code), plan lifecycle/migration/grandfathering, plan publish validation & approval, price history** → `PRD-plan-price-modeling-202605281200`. +- **Price resolution / override precedence / tier-volume-hybrid-commitment math / FX / `pricingSnapshotRef` composition** → the **rating** gear (evaluation core), `gears/bss/rating/docs/PRD.md`. +- **Usage → RatedCharge → BillableItem** → the **rating** gear (pipeline), `gears/bss/rating/docs/PRD.md`. **Subscription lifecycle** → `gears/bss/subscriptions/docs/PRD.md`. **Marketplace vendor ops (§4.8)** → `PRD-product-catalog-marketplace-202601120119`. + +> **Registry vs commercial boundary (normative):** A **SKU** is the unit of *what exists and how it is described/classified/published*; a **Plan/Price** is the unit of *how it is sold and charged*. This PRD stops at the SKU (the `bundle` type flag and the metering-unit declaration). Two corollaries: **(a) No monetization-model marker** — the registry carries no monetization-model field; only **usage** leaves a footprint (the metering-unit declaration); absence of a model marker is intentional, not a gap. **(b) `region` is visibility/legal scope only** — never a pricing dimension here. + +### 2.2 Predecessor PRDs and Scope Migration + +- **PRD-product-catalog-marketplace-202601120119** (combined Catalog §4.1 + Marketplace §4.8) — the **registry** scope items ("Product & SKU Management", "Catalog Versioning", catalog-level approval workflows, "Localization & Branding Infrastructure", taxonomy/attributes) are **superseded by this PRD**; its `UC-product-sku-management-202601121200` use-case doc is superseded here. Marketplace (§4.8) scope remains authoritative there pending a dedicated Marketplace PRD. +- **PRD-plan-price-modeling-202605281200** — authoritative for Plan, Price, PriceWindow, PriceList, Bundle composition, add-ons, billing descriptors, plan lifecycle/migration, plan publish validation & approval. This PRD provides the published SKU/Category/Attribute foundation and `CatalogVersion` that plan-price builds on. The two MUST stay consistent on: SKU identity & `bundle` type, metering-unit declaration, `PlanTier` taxonomy, and `CatalogVersion`. +- The **rating** gear (`gears/bss/rating/docs/PRD.md`; post-ADR-0002 consolidation of the former tariffs-pricing-logic + rating-engine PRDs) — downstream consumer of published SKUs and `CatalogVersion`; owns evaluation and charging. + +> **Recommendation on the combined PRD (§15):** Do **not** delete `PRD-product-catalog-marketplace-202601120119`. After this PRD + plan-price + Tariffs are approved, refactor it into a Marketplace-only PRD (§4.8). Until then, this PRD is authoritative for catalog-registry requirements only. + +## 3. Actors + +### 3.1 Human Actors + +#### Product Manager + +**ID**: `cpt-cf-bss-products-actor-product-manager` + +**Role**: Self-serves Product, SKU, category, and attribute authoring across offering types. +**Needs**: Product/SKU editor, taxonomy manager, attribute/localization editor, metering-unit and `PlanTier` selection, clone/templating. + +#### Catalog Admin + +**ID**: `cpt-cf-bss-products-actor-catalog-admin` + +**Role**: Governs the catalog: publishes `CatalogVersion`, runs bulk import/export, manages break-glass, force-completes stuck freezes, requests immutable-field corrections. +**Needs**: Approval/publish console, bulk-operations console, freeze monitoring & recovery, break-glass elevation. + +#### Finance Reviewer + +**ID**: `cpt-cf-bss-products-actor-finance-reviewer` + +**Role**: Reviews and approves finance-material catalog changes (`taxCategory`, `glCode`, `PlanTier`); second approver under the two-person rule for finance-bearing changes. +**Needs**: Pending-approval queue with diffs, pre-publish lint report, separation-of-duties enforcement. + +#### Auditor + +**ID**: `cpt-cf-bss-products-actor-auditor` + +**Role**: Inspects immutable version history, audit trail, and lineage; exports for compliance. +**Needs**: Version timeline with diffs, tenant-scoped audit retrieval, break-glass audit-export. + +#### Platform Owner + +**ID**: `cpt-cf-bss-products-actor-platform-owner` + +**Role**: Privileged cross-tenant operator; accesses foreign-tenant catalog only under time-boxed break-glass. +**Needs**: Break-glass read/audit-export (writes separately gated or disallowed in v1). + +### 3.2 System Actors + +#### Plan & Price Modeling + +**ID**: `cpt-cf-bss-products-actor-plan-price` + +**Role**: Consumes published SKU identity/type, metering-unit declaration, `PlanTier`, `CatalogVersion`; produces `SkuReferenceCount`, `freezeComplete` ack, and the bundle composition-completed signal that clears `compositionPending`. + +#### Rating (evaluation core + pipeline) + +**ID**: `cpt-cf-bss-products-actor-rating` + +**Role**: The one **rating** gear (post ADR-0002 consolidation; absorbs the former "Tariffs / Pricing Logic" consumer — id `…-actor-tariffs` retired): consumes published SKU refs + `CatalogVersion` for price resolution, and the metering-unit declaration to map usage. No authoring here. + +#### OSS Metering + +**ID**: `cpt-cf-bss-products-actor-oss-metering` + +**Role**: Emits usage values (external); consumes the metering-unit **declaration** on usage SKUs. + +#### Subscriptions + +**ID**: `cpt-cf-bss-products-actor-subscriptions` + +**Role**: Consumes published SKU refs + `PlanTier` + `replacedBy` for eligibility/composition/migration; produces `SkuReferenceCount`; consumes `mustMigrateBy` (post-v1 EOL). Owns live-subscription migration. + +#### Contracts & Agreements + +**ID**: `cpt-cf-bss-products-actor-contracts` + +**Role**: Consumes `CatalogVersion` snapshots for quotes; produces `SkuReferenceCount` (incl. draft/quote refs per producer contract) and `freezeComplete` ack. + +#### Billing & Invoicing + +**ID**: `cpt-cf-bss-products-actor-billing` + +**Role**: Consumes published SKU refs + `CatalogVersion`; produces `freezeComplete` ack (descriptor freeze). Billing descriptors are authored in plan-price and frozen into `CatalogVersion`. + +#### Marketplace & Vendor Portal + +**ID**: `cpt-cf-bss-products-actor-marketplace` + +**Role**: References published SKUs in vendor listings. Vendor ops remain in the Marketplace PRD (§4.8). + +#### Presentation / Portals + +**ID**: `cpt-cf-bss-products-actor-presentation` + +**Role**: Consumes catalog read models for browse/search cache warming. + +#### Events & Audit (Common Core) + +**ID**: `cpt-cf-bss-products-actor-events-audit` + +**Role**: Provides the shared event system: durable acceptance, per-consumer delivery/dead-letter state, bounded-backoff retry. Transport mechanics owned there. + +#### Tenant Identity (OSS/AMS + IdP) + +**ID**: `cpt-cf-bss-products-actor-oss-ams-idp` + +**Role**: Supplies `tenantId`, brand/region claims, OrgTier projection targets, and role claims. The registry MUST NOT mutate tenant topology. + +## 4. Operational Concept & Environment + +### 4.1 Module-Specific Environment Constraints + +- **Registry is upstream of all commercial modeling**: a SKU MUST be published before a Plan/Price can reference it. The registry MUST NOT require any downstream consumer to re-interpret mutable catalog state for **posted** periods; the `CatalogVersion` snapshot contract is authoritative (manifest §4.4). +- **Multi-tenant isolation**: tenant/brand/region scoping via IdP claims; deny-by-default at the gateway; cross-tenant access audited; time-boxed break-glass for platform-owner access. +- **`region` is visibility/legal scope, never pricing**; currency/price-region/FX live in plan-price/Tariffs. +- **Time**: scheduled publish (`publishAt`) and scheduled retirement (`effectiveAt`) are UTC; retirement lead-time ≥ 30 days (interim). +- **Eventing**: every state-changing mutation emits CloudEvents 1.0 onto the shared event system (Common Core) with `dataschema`+semver, correlation/causation, and per-aggregate ordering keys `(tenant, aggregate)`; **pseudonymous actor references only** (no direct PII). Delivery/ordering/dead-letter mechanics are owned by the common event system, not re-specified here. +- **Snapshots are financial records**: `CatalogVersion` snapshots + version history require a durability class (interim ≥ 11 nines / replicated storage), backup/restore with periodic checksum verification, and a cross-region/DR posture. + +## 5. Scope + +### 5.1 In Scope + +| **Feature** | **Priority** | **Notes** | +|-------------|--------------|-----------| +| Product definition | `p1` | Create/update Products: name, one required primary category + optional secondary, description, brand/region scope, lifecycle, version (§4.1 Product). | +| Category & taxonomy | `p1` | Hierarchical Category tree; cycle-free; uniqueness within parent. | +| SKU definition & typing | `p1` | Define SKUs typed `product`/`service`/`bundle`; stable accounting codes; `bundle` type flag only (composition is plan-price). | +| Metering-unit declaration | `p1` | Declare/validate the usage metering unit (unit identity only); governed de-listing. Consumed by plan-price, metering, Rating. | +| PlanTier taxonomy & SKU classification | `p1` | Own the `PlanTier` taxonomy and the SKU-level value; plan-price enforces presence at plan publish. Distinct from OrgTier. | +| Attribute management & localization (i18n) | `p1` | Extensible attribute schema; i18n with brand/region visibility and fallback `(locale,region,brand) → (locale,brand) → (default-locale,brand) → global`. | +| Identifiers & integrity | `p1` | Server-generated immutable `productId`/`skuId`; operator `skuCode` fixed-format, tenant-unique, immutable after first publish; field-mutability rules. | +| Product/SKU versioning & immutable history | `p1` | Internal revision per save; published version on publish; immutable history with diff; optimistic concurrency. | +| Product/SKU lifecycle, deprecation & retirement | `p1` | `draft → published [↔ deprecated] → retired` state machine; scheduled publish (`publishAt`) + scheduled retirement (`effectiveAt`); parent-child publish-ordering + cascade; retirement/EOL blocks new adoption, preserves references/snapshots, emits consumer handoff + lead-time. | +| Catalog versioning & snapshots (CatalogVersion) | `p1` | Stage/publish immutable full `CatalogVersion` with checksum + monotonic id; byte-identical re-resolution; emit `CatalogVersionPublished`; expose `freezeComplete`. | +| Catalog approval & publishing workflow | `p1` | Approval-gated publish with two-person rule above a typed materiality policy; approvals pinned to the approved revision; idempotent mutation boundary. Categories & attribute definitions are governed live entities. | +| Multi-tenant isolation & brand/region scoping | `p1` | Tenant/brand/region scoping via IdP claims; deny-by-default; audited cross-tenant; time-boxed break-glass; RBAC. | +| Eventing, audit & integration surface | `p1` | Publish CloudEvents 1.0 for every state-changing mutation with `dataschema`+semver + correlation/causation + ordering keys; pseudonymous actors; replay/bootstrap; immutable audit. | +| Data retention & right-to-erasure | `p1` | Defined retention for retired entities/versions/audit; reconcile immutable audit + version history with GDPR/CCPA (pseudonymize operator PII, retain financial records). | +| Catalog read models (core browse/search) | `p1` | Cache-first read models scoped by tenant/brand/region, bounded convergence; premise of the show-stopper NFRs (§7). | +| Bulk import (catalog onboarding at scale) | `p1` | Bulk create/update/import with idempotency + per-row partial-failure; imports land in draft and pass the gated publish. Required at ≥ 10K-SKU scale. | +| Advanced search, filter & faceting | `p2` | Rich faceted search/filter over the core read model. | +| Catalog lint / validation & snapshot export | `p2` | `validate(lint)` before publish (warn at SKU publish, blocking-with-override at `CatalogVersion` publish for uncomposed bundles); `export snapshot`. | +| Bulk export & bulk lifecycle tooling | `p2` | Deterministic snapshot export; bulk lifecycle (mass deprecate/retire) beyond parent→child cascade. | +| Product/SKU cloning / templating | `p3` | Clone to a new draft with new identifiers; explicit copy/reset field disposition; pricing not copied. | + +### 5.2 Out of Scope + +- **API schemas, storage DDL/data models, error-code taxonomies** — Design document(s). +- **Plan, Price, PriceWindow, PriceList, Bundle composition, add-on rules, billing descriptors, plan lifecycle/migration/grandfathering, plan publish validation & approval, price history, bulk price import, effective-price preview** — plan-price. This PRD owns only the SKU `bundle` **type flag** and stable SKU accounting codes, not their commercial use. +- **Price resolution, override precedence, tier/volume/hybrid/commitment math, FX, `pricingSnapshotRef` composition** — Tariffs. +- **Usage collection/normalization and charge rating** — OSS metering + metering-pricing-module + Rating. +- **Subscription lifecycle, entitlement enforcement, proration, recurring charge generation** — subscriptions-lifecycle + subscriptions-entitlements. +- **Contract negotiation, customer-specific overrides, committed-usage true-up, SLA penalties** — contracts-agreements. +- **Tax determination/statutory invoicing and revenue recognition** — Tax Engine / Billing / Finance. Catalog supplies only the tax-category/GL **code** on the SKU. +- **Marketplace vendor onboarding/certification, listings, fee schedules, payouts, fraud holds** — Marketplace PRD (§4.8). +- **Customer-facing storefront UI** — Presentation layer; Catalog provides read-model APIs. +- **Promotional/coupon pricing, eligibility, lifecycle** — Promotions (TBD) + plan-price. A *sellable* promo/$0/"Free" offering is a **normal registry SKU** here (identity, type, `PlanTier`, visibility); only its $0/promotional **price** is out of scope. Registry rules apply identically; no separate promo entity. +- **Tenant merge/split and brand transfer between tenants** — explicit non-goal for v1. +- **Binary / media assets** (images, icons, datasheets) — not stored here in v1; the registry MAY carry asset reference URIs as attribute values. +- **Recurring availability windows and scheduled `CatalogVersion` publish** — out of scope for v1 (entity-level scheduled publish IS in scope; `CatalogVersion` publish stays manual so the freeze protocol is untouched). +- **Configurable products / CPQ** — not modeled; the registry uses a concrete SKU per variant. +- **Product-to-product relationships beyond `bundle`, parent-child, and `replacedBy`/supersedes** — not modeled in v1; a governed catalog-relationship block is a post-v1 consideration (§15). + +## 6. Functional Requirements + +> **Content boundary**: FRs define WHAT the registry must do, not schemas. Any concrete field/flag/event/idempotency-key name or format is an illustrative handle; canonical field schemas, formats/regexes, error codes, event catalog, and payloads are owned by the gear's DESIGN (`gears/bss/products/docs/DESIGN.md`, pending). Full Given/When/Then acceptance detail is preserved in §12; interim configurable-policy defaults are in §17.1. + +### 6.1 Identifiers & Integrity + +#### Identifier contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-identifier-contract` + +`productId` and `skuId` **MUST** be server-generated immutable identifiers (never operator-supplied). `skuCode` **MUST** be operator-supplied, fixed-format, tenant-unique, reserved **atomically at create time**, and immutable after first publish; once first published a `skuCode` **MUST** be permanently reserved within the tenant and **MUST NOT** be reissued. Downstream consumers **MUST** bind to `skuId`. Products **MAY** carry an optional `productCode` under the same rules. + +**Rationale**: Stable system identity plus a protected human/external code is the foundation every downstream reference depends on. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Product/SKU field-mutability matrix + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-field-mutability-matrix` + +Mutability **MUST** be classified by lifecycle state: in `draft` all fields editable (incl. SKU→parent link and `skuCode`/`productCode`); after publish four buckets apply — **(i) structural identity** immutable and never correctable in place (remedied only by retire + clone); **(ii)** `type` and metering-unit declaration immutable but correctable via the governed fresh-zero path; **(iii) material-but-mutable** (`PlanTier`, `taxCategory`, `glCode`, `sellable`) change via a new published version under governance; **(iv)** other descriptive fields via a new published version. Illegal changes **MUST** be rejected fail-closed with an audited reason. The active-reference count **MUST** be sourced from `SkuReferenceCount` as the 3-state predicate; the registry **MUST NEVER** treat an entity as unreferenced absent a fresh watermark. + +**Rationale**: Protecting identity/external caches while allowing governed evolution requires a per-state, per-field classification. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Reference-signal sourcing, freshness & counting + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-reference-signal` + +The registry **MUST** consume the named `SkuReferenceCount` signal (per-producer watermark). Absence of a `skuId` under a **fresh** watermark ⇒ zero for that producer; `referenced` **MUST** be a boolean OR (any registered producer > 0); the registry **MUST NOT** sum across producers, and each producer dedups within itself. A **fresh-zero** across all registered producers ⇒ unreferenced; a **stale** or **never-received** watermark ⇒ conservatively referenced (stale alert distinct from never-received). Only **registered** producers count; Contracts **MUST** declare whether draft/quote references count, with identical semantics across mutability/correction/retirement. + +**Rationale**: A watermark-based OR predicate scales to 10K+ SKUs × N producers without dense zero publishing and never falsely frees a referenced SKU. + +**Actors**: `cpt-cf-bss-products-actor-plan-price` + +#### Immutable-field correction (zero-reference & break-glass) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-immutable-field-correction` + +For a correctable immutable field (`type` or metering-unit declaration; **never** structural identity), if `SkuReferenceCount` is **fresh and zero** across all producers the system **MUST** allow a governed re-publish under the two-person rule, increment the published version, and emit `SkuImmutableFieldCorrected`; absent a fresh-zero signal it **MUST** reject fail-closed. While the signal is entirely unavailable, a single-SKU correction **MAY** proceed only via **break-glass** (two-person + mandatory reason + `SkuCorrectionOverride` recording signal-unavailability), behind a feature flag OFF by default. + +**Rationale**: Corrections must be provably safe (fresh-zero) or explicitly break-glass-audited, never silent. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +### 6.2 Product & Taxonomy Definition + +#### Create a product + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-create-product` + +Creating a Product **MUST** generate a `productId`, persist as `draft` (published version 0) with full multi-tenant isolation and an audit entry (primary category optional at draft, required at publish; optional secondary categories allowed). Name uniqueness **MUST** be enforced on `(tenantId, brandId, normalized(name))` within any overlapping region scope; two same-named Products are allowed only when region scopes are **disjoint**. Indeterminate region overlap **MUST** fail closed with an operator-facing reason. The uniqueness key is the **canonical internal name**; localized display name/description are well-known attributes. + +**Rationale**: Multi-region catalogs need same-name coexistence without mangling, while same-region collisions must be rejected deterministically. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Manage taxonomy (create, rename, re-parent, retire, delete) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-manage-taxonomy` + +Category operations **MUST** validate name uniqueness within parent (re-checked on rename/re-parent), reject cycles, and reject exceeding configured max depth/children. A Product carries **exactly one primary category + zero or more secondary**; the read model **MUST** make it filterable under every assigned category. Categories are **governed live entities** (in-place, two-person-gated on material ops, audited) — not draft/publish-versioned. Retire/delete **MUST** be blocked while any active Product references the category (as primary or secondary) or active child categories exist. Each operation emits the corresponding `Category*` event. + +**Rationale**: Taxonomy reshaping affects browse for every published product, so it must be governed and cycle-free. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +### 6.3 SKU Definition & Classification + +#### Define a SKU + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-define-sku` + +Defining a SKU **MUST** link it to the Product, assign `skuId`/`skuCode`, type it `product`/`service`/`bundle`, and validate the per-type required-field set. A `bundle`-typed SKU **MUST** persist only the type flag and identity (composition authored in plan-price; blocking completeness enforced at `CatalogVersion` publish). Promotional/$0/"Free" SKUs **MUST** follow identical registry rules; there is no separate promo entity. + +**Rationale**: A uniform, type-aware SKU contract lets downstream bind without re-validation. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Sellable flag (offering eligibility) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-sku-sellable` + +A SKU **MUST** carry a dedicated `sellable` flag (default `true`), independent of lifecycle state (D-46, 2026-07-16). `sellable = false` marks a **composition/metering-only** SKU: it publishes normally, MAY be referenced as a bundle/plan component and MAY carry a metering-unit declaration, but **MUST NOT** be offerable standalone — plan-price enforces this as sellability-gate predicate **(6)** for standalone lines (bundle-**component** references are exempt; the component conjunction keeps predicates (1)–(5)). The flag is **material-but-mutable** (bucket iii of the mutability matrix): a change takes a new published version under governance and is frozen per `CatalogVersion`. + +**Rationale**: `published` means *referenceable*, not *offerable* — migrated catalogs carry technical/component SKUs that must exist, meter, and compose without ever being sold alone; conflating the two forces either unpublishable components or accidentally offerable internals. + +**Actors**: `cpt-cf-bss-products-actor-product-manager`, `cpt-cf-bss-products-actor-plan-price` + +#### Declare metering unit + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-metering-unit-declaration` + +Declaring a metering unit **MUST** validate it against the configured recognized-unit set and reject an unrecognized unit unless elevated approval marks a new validated unit. A usage SKU declares **exactly one** unit — the counted identity only. Pricing dimensions (`dimensionKey`) are **not** units: the declared dimension set over a meter is a plan-price concern (plan-level meter binding, §2.1), persisted on the plan-price revision and frozen into `pricingSnapshotRef` by Rating; multi-dimension usage on one unit **does not require separate SKUs**, and separate SKUs remain available where variants differ commercially (accounting codes, lifecycle). For a composite (derived) meter the SKU declares the composite's **output** unit; input units are referenced by the plan-price formula against the recognized-unit set and need no SKUs of their own. A draft whose unit was `deprecated` before first publish **MUST** be treated as a new declaration and rejected. The declared unit **MUST** be carried on publish; this PRD **MUST NOT** compute charges. + +**Rationale**: Declaring the unit is what defines a usage SKU; validation prevents downstream rate corruption. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Metering-unit de-listing + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-metering-unit-delisting` + +De-listing a metering unit **MUST** be rejected while ≥ 1 `published` SKU references it; the system **MUST** instead support marking it **deprecated** (no new declarations) with full removal only once unreferenced. A unit's identity/semantics are **immutable** (e.g. `GB-storage` MUST NOT be silently redefined to GiB); a correction is a new unit + deprecation of the old. De-listing/deprecation **MUST** be audited and **MUST NOT** mutate any frozen snapshot. + +**Rationale**: Redefining a live unit corrupts every downstream rate; deprecate-then-remove protects existing references. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### PlanTier classification + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-plantier-classification` + +Assigning `PlanTier` **MUST** validate against the taxonomy owned here and carry it on the published SKU. A `PlanTier` value has a **stable tier code** as identity; **rename affects the display label only**. Managing the taxonomy (add/rename/retire) is a governed (two-person), audited operation emitting `PlanTierUpdated`; a value **MUST NOT** be retired while any `published` SKU carries it (deprecate-then-retire). The taxonomy **MUST** be seeded with a neutral value. `PlanTier` **MUST NOT** be conflated with OrgTier; plan-publish presence enforcement is delegated to plan-price. + +**Rationale**: Stable tier codes keep SLA/quota policies from rippling on rename; universal presence is a manifest mandate. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Stable accounting codes on SKU + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-accounting-codes` + +Setting tax-category and GL codes **MUST** persist them as stable codes and **validate each against a configured recognized set** (owner = Finance, deprecate-then-remove governance). The codes are **required at publish for `product`/`service`-type SKUs**; a SKU published without a required code is unpostable and **MUST** be rejected. The system **MUST NOT** compute tax or post to GL (codes only). + +**Rationale**: Validating codes at authoring prevents unpostable SKUs surfacing weeks later at ERP export. + +**Actors**: `cpt-cf-bss-products-actor-finance-reviewer` + +### 6.4 Attributes & Localization + +#### Localized attributes, well-known display fields, and definition lifecycle + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-localized-attributes` + +Adding i18n attribute **values** **MUST** validate against the attribute **definition**, require the default-locale value (rejected at publish if absent), and resolve locale via `(locale, region, brand) → (locale, brand) → (default-locale, brand) → global` (default-locale resolved per brand, falling back to tenant default). The registry **MUST** seed **well-known display attribute definitions** (localized display name/description for Product/SKU/Category). Managing attribute **definitions** is a governed live-entity operation emitting `AttributeDefinitionUpdated`; changes **MUST** be backward-compatible and follow a deprecate-then-remove lifecycle. The registry **MUST** provide an ungoverned, size-bounded, non-localized, search-excluded, PII-prohibited **metadata map** for machine metadata. + +**Rationale**: Localized display without a second identity key, plus a governed/ungoverned split, keeps portals correct and integrations from flooding the definition registry. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +### 6.5 Versioning, Lifecycle & Deprecation + +#### Revision vs published version + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-revision-vs-version` + +The system **MUST** increment the **internal revision** on every save (rejecting stale-revision writes via optimistic concurrency) while the **published version** increments only on publish. Consumers and `CatalogVersion` **MUST** reference the published version; historical versions **MUST** be retained with a diff and **MUST NOT** be modifiable. **Version binding** **MUST** be explicit: a new reference binds to the latest published version at bind time; a frozen reference keeps its snapshot; a bound-but-not-yet-frozen reference re-resolves at freeze and **MUST** surface a version-change diff to the freezing module rather than silently swapping. + +**Rationale**: Separating draft churn from published versions keeps downstream snapshots and quotes stable and auditable. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Lifecycle transitions & reversibility + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-lifecycle-transitions` + +The state machine `draft → published [↔ deprecated] → retired` (+ `draft → discarded`) **MUST** allow only its defined transitions, treat `retired` and `discarded` as terminal (revival only via clone), and allow downstream referencing only for `published`/`deprecated`. Entity publish **MAY** be **scheduled** (`publishAt`, UTC) with approval pinned at scheduling time and re-validated fail-closed at activation. Publication of incomplete entities **MUST** be rejected. There is **no `unpublish`** and **no in-place rollback** — retraction/reversion is `deprecate`/`retire` + a new version (forward-only). + +**Rationale**: A constrained, forward-only state machine keeps caches/snapshots/posted content consistent. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Parent-child (Product↔SKU) lifecycle integrity + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-parent-child-integrity` + +A SKU **MUST NOT** reach `published` while its parent Product is not `published`, and the system **MUST NOT** orphan a `published` SKU under a `retired` Product. A SKU's brand/region scope **MUST** be contained within its parent's; a scope-narrowing Product publish **MUST** fail closed while any non-`retired` child would fall outside. Retiring a Product with non-`retired` SKUs **MUST** require confirmed **cascade-retire** (partial by design, recording `direct` vs `cascaded` provenance; EOL-requiring children left un-retired and listed; never-published children auto-`discarded`). When a partial cascade leaves children, the parent **MUST** remain non-`retired` and its deferred-retire intent tracked/queryable. + +**Rationale**: Hierarchy and scope-containment invariants prevent orphaned or out-of-scope published content. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Deprecation (governed sub-state) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-deprecation` + +Marking a `published` SKU `deprecated` **MUST** move it to the `deprecated` sub-state, mark it so consumers **block new adoption** while existing references continue, and emit `SkuDeprecated`. `deprecated` **MUST** be a tracked, queryable state (not a flag), recording provenance `direct` (vs `cascaded`). The registry marks and exposes; the consumer enforces the new-adoption block (CI-verified via the seam suite). + +**Rationale**: A tracked sub-state makes the new-adoption guard testable and consistent with the composition-pending pattern. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Un-deprecation + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-undeprecation` + +`deprecated → published` **MUST** be allowed under the two-person rule, re-open new adoption, and emit `SkuUndeprecated`. Un-deprecating a **Product** reverses **only `cascaded`** child deprecations and **MUST NOT** revive a child's `direct` deprecation. The transition **MUST** be audited; a `retired` entity **MUST NOT** be reversible. + +**Rationale**: Provenance-aware reversal prevents accidentally reviving individually-deprecated children. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Retirement / EOL consumer handoff + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-retirement-eol` + +Retiring a referenced SKU **MUST** require explicit confirmation with the active-reference count shown, then run as a **scheduled transition**: force `deprecated` at initiation (new adoption blocked immediately, still browsable), preserve snapshots, emit `SkuRetired`/`ProductRetired` with `{ skuId, fromVersion, reason, replacedBy?, mustMigrateBy?, effectiveAt }` honoring the ≥ 30-day lead-time, then flip to `retired` at `effectiveAt`. The registry is SoR for `replacedBy` (a successor `published` SKU). **v1 = plain retirement + grandfathering only; EOL-with-`mustMigrateBy` is a defined-but-deferred post-v1 follow-on** that MUST stay disabled until the consuming subscriptions-lifecycle AC exists and is referenced by number, and requires a consumer acknowledgment contract (lapsed ack ⇒ suspend fail-closed + `SkuEolSuspended`). + +**Rationale**: A defined lead-time state and successor pointer let consumers migrate safely without undefined limbo. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +### 6.6 Catalog Versioning & Snapshots + +> **Two publish layers.** A Product/SKU has its own **published version** that increments each time that entity is published. A **`CatalogVersion`** is a catalog-wide immutable snapshot; a SKU can be published yet not appear in any `CatalogVersion` until the next catalog publish. New references bind to the latest published entity version at bind time; posted/contractual references freeze to a `catalogVersionId`. + +#### Publish an immutable catalog version + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-catalog-version-publish` + +Publishing a `CatalogVersion` **MUST** persist a **full** snapshot (published Product/SKU set + their versions + current categories/attributes captured together), assign a monotonic `catalogVersionId`, generate a checksum, record `stagedAt`/`publishedAt`, capture the freeze-participant set, and make it immutable (storing references to plan-price/Contracts/Billing content, not that content). An uncomposed `bundle` SKU **MUST** require explicit two-person override (blocking-with-override) and be flagged `compositionPending = true`. It **MUST** emit `CatalogVersionPublished` and expose per-version `freezeComplete`. A published `CatalogVersion` **cannot be withdrawn or rolled back** (roll-forward N+1 only); snapshot boundary is the whole tenant (serialized). + +**Rationale**: A full, immutable, checksummed snapshot is the reproducibility anchor for posted invoices and contracts. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Snapshot reproducibility + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-snapshot-reproducibility` + +Re-resolving a `catalogVersionId` at any future time **MUST** yield a byte-identical checksum and unchanged **registry** content (manifest §4.4). `CatalogVersion` **MUST** be exposable as **one component** of a downstream `pricingSnapshotRef` without asserting it equals the full snapshot; referenced-module content reproducibility is governed by the freeze protocol. + +**Rationale**: Byte-identical re-resolution is required for posting immutability and dispute defensibility. + +**Actors**: `cpt-cf-bss-products-actor-billing` + +#### Cross-module snapshot freeze atomicity + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-freeze-atomicity` + +The system **MUST** expose a `freezeComplete` flag per `catalogVersionId` and **reject resolution for posted/contractual use** until all registered freeze-participants acknowledge, with a bounded timeout that **fails closed**. Read-only browse **MAY** proceed during the freeze window. The resolution API **MUST** require the consumer to **declare intent** (`browse` vs `posted/contractual`) so a consumer cannot post against a not-yet-`freezeComplete` version by mislabeling its call (consumer-side obligation, CI-verified in the seam suite). + +**Rationale**: Cross-module atomicity prevents posting against a partially-frozen snapshot. + +**Actors**: `cpt-cf-bss-products-actor-plan-price` + +#### Freeze recovery & force-completion + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-freeze-recovery` + +For a `CatalogVersion` past the freeze timeout the system **MUST** identify each non-acknowledging participant, support an **idempotent re-trigger** of the fan-out, and support **force-completion** under the two-person rule that records each missing participant as explicitly **not-frozen** and emits `FreezeForceCompleted`. Force-completion **MUST NOT** mark missing content as frozen; the default is **pinned fail-closed** for that participant's content (auto-fallback is an off-by-default later enhancement). + +**Rationale**: A stuck freeze must be recoverable without silently marking content frozen. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Freeze-participant set governance + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-freeze-participant-governance` + +Freeze-participant membership changes **MUST** be governed (two-person), audited, and each `CatalogVersion` **MUST** snapshot the participant set at publish time so a historical version re-resolves `freezeComplete` against its original participants. A participant removed after publish **MUST NOT** retroactively flip that version's `freezeComplete`. + +**Rationale**: Historical versions must re-resolve against the participants that existed at publish. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Grandfathering invariant + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-grandfathering-invariant` + +The registry **MUST** guarantee a grandfathered frozen snapshot is **never mutated**; retirement/deprecation affects only new adoption, never existing frozen references. Grandfathering **eligibility policy** is owned by plan-price / subscriptions-lifecycle; this requirement makes the delegation auditable from the registry side. + +**Rationale**: Existing terms must persist byte-identically after the underlying SKU is deprecated/retired. + +**Actors**: `cpt-cf-bss-products-actor-subscriptions` + +#### Uncomposed-bundle adoption guard + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-bundle-adoption-guard` + +A `bundle` SKU published with the uncomposed override **MUST** carry `compositionPending = true` until plan-price composes it, and consumers **MUST** treat `compositionPending` SKUs as **not-yet-adoptable** for new references. Clearing it **MUST** be driven by a plan-price composition signal, audited, and emitted as `BundleCompositionCompleted` (producing a new published version, never mutating a prior frozen `CatalogVersion`). + +**Rationale**: An incomplete bundle must be reproducible-as-pending and blocked from new adoption until composed. + +**Actors**: `cpt-cf-bss-products-actor-plan-price` + +### 6.7 Approval, Publishing & Eventing + +#### Materiality-gated publish + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-materiality-gated-publish` + +A **material** change (touching `PlanTier`/metering-unit/`taxCategory`/`glCode`, a lifecycle transition to `published`/`deprecated`/`retired`, a Category create/rename/re-parent/retire/delete, a material attribute-definition change, or exceeding the configured affected-entity count) **MUST** enforce a two-person rule: ≥ two distinct approvers, each distinct from the author and holding CatalogAdmin or FinanceReviewer; a **finance-material** field (`taxCategory`, `glCode`, `PlanTier`) **MUST** include ≥ 1 FinanceReviewer. An approval **MUST** be **pinned to the internal revision**; any subsequent edit invalidates it and re-queues with the diff re-presented. The materiality rule **MUST** be a typed, configurable policy with an enforceable interim default (§17.1); a rejection returns the entity to `draft` with reason recorded. + +**Rationale**: Two-person control with separation of duties and revision-pinning prevents unauthorized or bypassed publishes. + +**Actors**: `cpt-cf-bss-products-actor-finance-reviewer` + +#### Idempotent authoring boundary + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-idempotent-authoring` + +For a retried create/update/publish with an idempotency key, the same key + identical payload **MUST NOT** create duplicate entities, versions, or events; the key **MUST** be scoped per tenant + endpoint + client key and retained ≥ 24h **and never less than the maximum freeze timeout**. Reuse with a **different** payload **MUST** be rejected as a conflict (no silent no-op). + +**Rationale**: Idempotency prevents duplicate publishes on retry, including after the freeze window. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Registry eventing & audit + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-registry-eventing-audit` + +Every state-changing mutation **MUST** publish the corresponding CloudEvents 1.0 event onto the shared event system, stamping correlation/causation + idempotency key + per-aggregate ordering keys `(tenant, aggregate)`; delivery/ordering/durability are owned by the common event system. Every state-changing requirement **MUST** map to exactly one named event (or an explicit "no event" decision in Design). Event payloads **MUST** carry **pseudonymous actor references only** (never direct operator PII). The mutation **MUST** be recorded in an immutable, queryable audit trail. Plan/Price/Bundle-composition events **MUST NOT** be emitted here (owned by plan-price). + +**Rationale**: Complete, pseudonymous, ordered eventing + immutable audit is what makes erasure (AC #35) and downstream consumption work. + +**Actors**: `cpt-cf-bss-products-actor-events-audit` + +#### Event schema versioning & replay + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-event-versioning-replay` + +Every event **MUST** carry a `dataschema` URI with a semantic version; a consumer pinned to `vN` **MUST** deserialize `vN+1` (new fields optional with defaults); out-of-order/duplicate delivery beyond the idempotency window **MUST** be detectable via `(tenant, aggregate, sequence)`. The system **MUST** provide a **bootstrap path** (latest `CatalogVersion` + event tail) for published-scope consumers, and **MUST** detect when a consumer checkpoint predates the available event tail and **fail loudly**. + +**Rationale**: Forward-compatible schemas + a bootstrap path let consumers evolve and recover without full historical replay. + +**Actors**: `cpt-cf-bss-products-actor-events-audit` + +### 6.8 Multi-Tenancy & Read Models + +#### Tenant/brand/region isolation & break-glass + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-tenant-isolation-breakglass` + +Cross-scope query/mutation **MUST** be denied by default at the API gateway and the attempt audited. Privileged platform-owner cross-tenant access **MUST** use **break-glass** elevation that is time-boxed, reason-required, separately alertable, and itself two-person-approved or post-hoc-reviewed; standing cross-tenant access **MUST NOT** be granted. + +**Rationale**: Cross-tenant catalog leakage is a critical commercial/competitive incident class. + +**Actors**: `cpt-cf-bss-products-actor-platform-owner` + +#### Break-glass action scope + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-breakglass-action-scope` + +Break-glass **MUST** permit **read and audit-export only**; any write/publish under break-glass **MUST** be separately gated (two-person + distinct alert) or disallowed in v1. Every break-glass action **MUST** be individually audited with the elevation reason and correlation ID. + +**Rationale**: Elevation must not silently grant write authority in a foreign tenant. + +**Actors**: `cpt-cf-bss-products-actor-platform-owner` + +#### Cache-first browse/search with bounded convergence + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-cache-first-browse` + +Browse/search/filter **MUST** be served from cache-first read models scoped to the caller's tenant/brand/region, converging within its own budget (interim p99 < 2 s after write commit). Stale reads during the window **MUST** be safe (never expose unpublished or cross-scope content) and **MUST** carry the `asOfCatalogVersion` staleness signal. The per-state visibility contract **MUST** hold: `published` browsable; `deprecated` browsable with a machine-readable flag and excludable by filter; `retired` excluded from default browse and retrievable only via explicit history query. + +**Rationale**: The show-stopper read NFRs and Performance vector are premised on a cache-first read model with a safe staleness contract. + +**Actors**: `cpt-cf-bss-products-actor-presentation` + +### 6.9 Bulk Operations + +#### Bulk import/export + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-bulk-import-export` + +Bulk import/export **MUST** apply **per-row idempotency**, report per-row success/failure (no hidden partial failure), and never leave a partially-inconsistent published state. Dependent rows **MUST** apply two-phase (stage-all-then-commit) or dependency-ordered, never committing an orphan. Idempotency operates at two levels (batch key + per-row keys). A bulk operation **MUST** emit a coalesced `CatalogBulkOperationCompleted` (no event storm). **Bulk import lands entities in `draft`**; publication remains gated, approved against an **aggregated change report** (counts, per-type summary, sample, lint findings). Export **MUST** be deterministic for a given `catalogVersionId`. + +**Rationale**: Onboarding/migration at ≥ 10K-SKU scale cannot be row-by-row and must stay consistent and governed. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +### 6.10 Cloning + +#### Clone a product/SKU + +- [ ] `p3` - **ID**: `cpt-cf-bss-products-fr-clone` + +Cloning a source Product/SKU (draft, published, or **retired** — the sanctioned revival path) **MUST** create a new `draft` with new `productId`/`skuId` and a new `skuCode`/optional `productCode` (system-suggested, operator-overridable, atomically reserved), copying structure/attributes/scoping/category/`PlanTier`/metering-unit while resetting lifecycle and version counters and **never copying** pricing/plan content. The cloned metering unit, `PlanTier`, and category assignment **MUST** be re-validated against live registries; the clone **MUST** fail or force re-selection if any was de-listed/deprecated/retired. It **MUST** record a `clonedFrom` reference and **MUST NOT** affect the source. + +**Rationale**: Cloning accelerates catalog expansion and is the safe revival path for retired items, provided re-validation runs. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +### 6.11 Data Retention & Erasure + +#### Retention & right-to-erasure + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-retention-erasure` + +The system **MUST** retain financial/version/audit records for the configured retention duration and satisfy erasure of **actor PII** by **pseudonymizing** it across audit, entity version fields, and the actor identity-reference map — never deleting immutable financial/version records; because events carry only pseudonymous actor references, updating the reference map completes erasure without touching immutable event streams. Attribute/description free-text **MUST NOT** contain personal data — enforced by a **validation block at write** (hard prohibition, no erasure carve-out, fail-closed on uncertainty, curated allow-list for legitimate person-named products). Erasure **MUST NOT** break `CatalogVersion` reproducibility or audit completeness. + +**Rationale**: Content-erasure is logically incompatible with byte-identical reproducibility, so PII is kept out at write and actor PII is pseudonymized. + +**Actors**: `cpt-cf-bss-products-actor-auditor` + +### 6.12 Cross-PRD Consistency + +#### Registry ↔ plan-price seam + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-plan-price-seam` + +There **MUST** be a shared schema-version pin and a CI contract test that fails when registry and plan-price diverge on any shared field (`skuId`, `bundle` type, metering-unit declaration, `PlanTier`, `CatalogVersion`); a runtime divergence **MUST** fail closed (reject the dependent plan publish). The same suite **MUST** assert consumer-side lifecycle obligations: reject adoption of `compositionPending`/`deprecated` SKUs; reject a usage binding when the target SKU has no declared unit (and reject/warn when its unit is `deprecated`) — **this is where usage-completeness is enforced**; consume `mustMigrateBy` (post-v1); resolve grandfathered refs against the frozen snapshot; re-validate on `SkuImmutableFieldCorrected`; and declare intent before `freezeComplete` on posted/contractual resolution. Assertions are authorable only once the referenced counterpart AC exists. + +**Rationale**: A CI-verified seam turns delegated boundaries into enforced contracts rather than assumptions. + +**Actors**: `cpt-cf-bss-products-actor-plan-price` + +#### Monetization-model traceability + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-monetization-traceability` + +The PRD **MUST** expose a traceability map (§17.2) so the registry's deliberate lack of a monetization-model marker does not read as an unmet requirement: flat/per-seat/tiered/volume/hybrid/commitment → authored/evaluated in plan-price + Tariffs; usage → metering-unit declaration here + binding/rating downstream. Absence of a model marker on a SKU **MUST** be treated as intentional, not a missing field. + +**Rationale**: Explicit traceability prevents the boundary from being mistaken for a gap. + +**Actors**: `cpt-cf-bss-products-actor-finance-reviewer` + +### 6.13 Operational Resilience & Concurrency + +#### Expected failure behavior + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-expected-failure-behavior` + +Invalid/conflicting authoring **MUST** fail closed with an audited reason and **MUST NOT** partially apply, for each of: stale-revision write, duplicate idempotency key with different body, taxonomy cycle, unrecognized metering unit without elevation, publish of an incomplete entity, immutable-field change without a valid correction path, reissue of a reserved `skuCode` and concurrent `skuCode` collision, EOL retirement without an acknowledged migration consumer (post-v1), publishing a SKU under a non-`published` parent, a SKU scope falling outside its parent, authoring/cloning against a de-listed/deprecated unit, a bulk row whose in-batch dependency failed, adopting a `compositionPending` bundle, an indeterminate region overlap, and a retention process that would orphan a live grandfathered reference. + +**Rationale**: A single enumerated fail-closed contract keeps negative paths deterministic and auditable. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Event delivery resilience + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-event-delivery-resilience` + +The **shared event system** **MUST** provide bounded-backoff retry, per-consumer delivery state, and an **audited dead-letter** path with alerting (transport owned there). The **registry's own** obligations are limited to: not reporting emission success until the event is **durably accepted**, **surfacing** the per-consumer delivery/dead-letter state as a projection, and never mutating registry state on a delivery failure. During a bus outage, mutations **MAY** commit with events to a durable **outbox** for later emission; the propagation clock starts at durable bus acceptance, not at commit. + +**Rationale**: Resilience mechanics belong to the bus; the registry must not falsely report propagation or lose events. + +**Actors**: `cpt-cf-bss-products-actor-events-audit` + +#### CatalogVersion publish concurrency + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-catalog-publish-concurrency` + +Concurrent publishes within a tenant **MUST** serialize; `catalogVersionId` **MUST** be allocated monotonically without gaps or collisions; a staged entity mutated or retired between stage and publish **MUST** cause that publish to **re-validate fail-closed** (rejected, naming the changed entity) rather than freezing stale or partial content. + +**Rationale**: Per-tenant serialization + re-validation guarantees no published version contains concurrently-invalidated content. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### Fail-safe duration tripwire + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-failsafe-tripwire` + +While operating in `SkuReferenceCount`-unavailable fail-safe mode, when break-glass immutable-field corrections exceed a configured rate (interim > 5 in 30 days) the system **MUST** raise an escalation alert and **reclassify `SkuReferenceCount` delivery as a release blocker**, so unbounded degraded operation is detected and escalated, not normalized. + +**Rationale**: The tripwire bounds the fail-safe operational debt in time, not merely acknowledging it. + +**Actors**: `cpt-cf-bss-products-actor-catalog-admin` + +#### `skuCode` reservation concurrency + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-skucode-reservation-concurrency` + +For two concurrent reserve requests for the same `skuCode` within a tenant, the system **MUST** atomically reserve at **create** time, admit exactly one, and reject the other fail-closed with an audited reason; a `draft` reservation **MUST** block a second draft until released or discarded. A `skuCode` changed while still `draft` **MUST** release the previous code; **discarding a never-published draft MUST also release** its `skuCode`/`productCode` reservation (permanent reservation applies only from first publish). + +**Rationale**: Atomic reservation prevents duplicate codes while letting abandoned drafts free the namespace. + +**Actors**: `cpt-cf-bss-products-actor-product-manager` + +#### Reference-producer registration + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-reference-producer-registration` + +Only **registered** producers' signals or silence **MUST** factor into the `referenced` predicate; an unregistered producer's absence **MUST NOT** pin every SKU immutable. Producer-set membership **MUST** be a governed, audited change **snapshotted symmetrically with the freeze-participant set**, and onboarding a new producer **MUST NOT** retroactively flip historical mutability/retirement decisions. + +**Rationale**: Registration prevents a not-yet-onboarded producer from freezing the whole catalog and keeps history stable. + +**Actors**: `cpt-cf-bss-products-actor-subscriptions` + +#### Grandfathered-snapshot retention coupling + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-fr-grandfathered-retention-coupling` + +For a `catalogVersionId` referenced by ≥ 1 **live** grandfathered reference, the snapshot **MUST** remain byte-identically resolvable for as long as a live reference exists, **regardless of the statutory-max retention clock**; retention expiry **MUST** be gated on no live references to that `catalogVersionId`. Because per-SKU `SkuReferenceCount` carries no version dimension, version-liveness **MUST** be sourced from per-version freeze-registration records (or a `(catalogVersionId, producer)` producer contract), never from the SKU-level count alone. A retention process that would orphan a live reference **MUST** fail closed with an alert. + +**Rationale**: Silently GC'ing a snapshot under a live contract breaks reproducibility for every reference frozen to it — a compliance event. + +**Actors**: `cpt-cf-bss-products-actor-billing` + +#### Pre-publish lint report + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-fr-prepublish-lint` + +The `validate(lint)` operation before `CatalogVersion` publish **MUST** return a **structured, per-entity report** of every override-requiring or attention condition (uncomposed `bundle` SKUs, missing default-locale attribute values, declarations against a `deprecated` unit) so the two-person override is an **informed** decision and the audit records **what** was overridden. + +**Rationale**: An informed override beats a blind acknowledgment and produces a meaningful audit trail. + +**Actors**: `cpt-cf-bss-products-actor-finance-reviewer` + +## 7. Non-Functional Requirements + +### 7.1 NFR Inclusions + +> Numeric targets are binding **design targets** until the program NFR workshop (scheduled within 2 weeks of PRD approval; named DRI = BSS Program Lead). Interim configurable-policy defaults are in §17.1. + +#### Read latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-read-latency` + +Browse/search reads within a tenant partition **MUST** meet **p95 < 100 ms** over a 5-minute window on a warm read model holding 10K SKUs/tenant with ≥ 100 concurrent readers, sustained via cache-first read models and tenant/brand/region partitioning. + +**Threshold**: p95 < 100 ms @ 10K SKUs/tenant, ≥ 100 concurrent readers. + +**Rationale**: Slow catalog reads degrade portal/sales UX. + +#### Read throughput + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-read-throughput` + +The cache-first read model **MUST** sustain **≥ 2,000 read QPS per tenant partition** at the read-latency target. + +**Threshold**: ≥ 2,000 read QPS/tenant partition at p95 < 100 ms. + +**Rationale**: Peak browse/search traffic must not breach latency. + +#### Publication propagation + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-nfr-publication-propagation` + +Downstream event availability (incl. fan-out) after an approved publish **MUST** occur within **< 3 s** — a component preceding freeze acks, distinct from read-model convergence (< 2 s) and end-to-end posting-safe (< 5 s). + +**Threshold**: event availability < 3 s after publish (p99). + +**Rationale**: Delayed publication yields stale offerings downstream; the three nested budgets must not collapse to one. + +#### End-to-end posting-safe budget + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-posting-safe-budget` + +From write commit to "posting-safe" (read-model converged **and** all participants' `freezeComplete` acknowledged) **MUST** be **p99 < 5 s**; if the freeze times out the version **MUST** remain non-posting-safe (fail closed). This composite is a **program-level SLO** decomposed into a registry-owned `commit → event-durably-published` budget and per-participant `event → ack` budgets. + +**Threshold**: p99 < 5 s commit → posting-safe (fail-closed on freeze timeout). + +**Rationale**: Downstream needs a single SLA to design against; freeze acks follow fan-out. + +#### Snapshot archival & cold-resolution SLA + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-snapshot-archival-dr` + +Cold `catalogVersionId` re-resolution **MUST** remain byte-identical and meet a looser-than-hot target (interim p95 < 2 s). `CatalogVersion` snapshots + version history are **financial records** with a durability class (interim **≥ 11 nines** / replicated storage), backup/restore with **periodic checksum restore verification**, and a cross-region/DR posture with RPO/RTO (set at the NFR workshop). Availability SLOs do **not** substitute for durability. + +**Threshold**: cold p95 < 2 s; durability ≥ 11 nines; periodic restore verification; RPO/RTO TBD (workshop). + +**Rationale**: Silently losing one snapshot breaks reproducibility for every contract frozen to it — a compliance event. + +#### Scale & extensibility limits + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-scale-extensibility` + +The system **MUST** support **≥ 10K SKUs per tenant** without breaching read latency, within configured limits (max attributes/entity, max taxonomy depth, max children/node). The scale model **MUST** also bound tenant count, total cardinality, and **`CatalogVersion` growth** (full-snapshot-per-publish is the dominant cost driver) with a publishes/day/tenant target set at the workshop. + +**Threshold**: ≥ 10K SKUs/tenant; extensibility limits + publish-frequency target per workshop. + +**Rationale**: Full-snapshot economics and extensibility limits bound the design. + +#### Graceful degradation & staleness exposure + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-nfr-graceful-degradation` + +Above the throughput ceiling or read-model lag, the system **MUST** shed or queue excess load **without ever serving cross-scope or unpublished content**, and **MUST** expose staleness via the **same `asOfCatalogVersion` mechanism** (one signal, machine-readable) — no silently-stale degraded response. + +**Threshold**: zero cross-scope/unpublished leakage under overload; machine-readable `asOfCatalogVersion` on every stale response. + +**Rationale**: Overload must never compromise isolation or hide staleness. + +#### Determinism & integrity + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-determinism-integrity` + +Version immutability, taxonomy acyclicity, SKU identity uniqueness, and metering-unit validity **MUST** be enforced fail-closed, and posted-period `CatalogVersion` snapshots **MUST** remain immutable. + +**Threshold**: 100% fail-closed enforcement of the registry invariants. + +**Rationale**: The registry is the integrity foundation all monetization binds to. + +#### Backward-compatible schema evolution + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-backward-compatible-evolution` + +A consumer pinned to schema `vN` **MUST** successfully deserialize a `vN+1` payload (new fields optional with defined defaults); a CI contract test **MUST** assert backward compatibility on every schema change. + +**Threshold**: 100% `vN`→`vN+1` deserialization; CI-guarded on every schema change. + +**Rationale**: New product categories/fields must not break published content or downstream contracts. + +#### Availability & audit completeness + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-nfr-availability-audit` + +The cache-first **read** path **MUST** meet **99.9%** availability and the **write/publish** path **99.5%** (reads must not block downstream when writes degrade); write paths **MUST** be fully audited even during partial failures. + +**Threshold**: read 99.9% / write 99.5% availability; 100% write-path audit. + +**Rationale**: Reads feeding portals/sales must stay up independently of write degradation. + +### 7.2 NFR Exclusions + +- **Pricing/rating performance** — owned by plan-price / Tariffs / Rating; the registry only serves published primitives. +- **Usage collection/normalization throughput** — OSS metering / Usage Collector. +- **Event-bus transport SLOs (delivery latency, DLQ retention)** — owned by the common event system (Common Core); the registry states only its own emission/projection obligations. +- **Storefront UX performance / accessibility (WCAG) / i18n rendering** — Presentation layer / frontend DESIGN. +- **Marketplace listing/search performance** — Marketplace PRD (§4.8). + +## 8. Five Quality Vectors Analysis + +| **Quality Vector** | **Show-Stopper Requirements** | **Rationale** | +|--------------------|-------------------------------|---------------| +| **Efficiency** | Product/SKU/category/attribute changes MUST be operator self-service with automated versioning and approvals — no engineering involvement for routine registry change. | Manual catalog management blocks catalog growth and time-to-market. | +| **Reliability** | Immutable Product/SKU versioning, byte-identical `CatalogVersion` snapshots, 100% audited write paths, fail-closed publish validation, and a CI contract test guarding the registry↔plan-price seam. | The registry is the foundation all monetization binds to; silent drift or lost history breaks downstream snapshots and compliance. | +| **Performance** | Browse/search p95 < 100 ms and ≥ 2,000 read QPS/tenant partition via cache-first read models; nested propagation budgets (convergence < 2 s, propagation < 3 s, posting-safe < 5 s, cold-version < 2 s); graceful degradation. | Slow catalog reads degrade portal/sales UX; delayed publication yields stale offerings. | +| **Security** | Complete tenant/brand/region isolation (deny-by-default), RBAC, two-person rule for material changes, time-boxed break-glass for cross-tenant access, retention/erasure reconciled with immutable audit, minimal PII in events. | Cross-tenant leakage is a commercial risk; unauthorized publish is a fraud risk; standing super-access and unbounded retention are compliance risks. | +| **Versatility** | Extensible attributes/taxonomy and a type-agnostic SKU model (product/service/bundle) with backward-compatible schema evolution (`vN` deserializes `vN+1`). | New product categories must be added without breaking published content or downstream contracts. | + +## 9. Public Library Interfaces + +> The registry is a backend service, not a client library. Interfaces below are high-level contracts; concrete API schemas, endpoints, event payloads, and DDL belong in DESIGN. + +### 9.1 Public API Surface + +#### Catalog authoring & publish + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-interface-authoring-publish` + +**Type**: command/authoring + approval-gated publish API (shape in Design) + +**Stability**: stable (contract intent), schema unstable (Design owns) + +**Description**: Create/update Products, SKUs, categories, attributes, `PlanTier`; declare metering units and accounting codes; lifecycle transitions; two-person-gated publish of entities and `CatalogVersion`; idempotent by key. Requires the resolution caller to **declare intent** (`browse` vs `posted/contractual`). + +**Breaking Change Policy**: Major version bump; idempotency-key and intent-declaration semantics are part of the contract. + +#### Catalog read model (browse/search) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-interface-read-model` + +**Type**: cache-first query/read API (shape in Design) + +**Stability**: stable (contract intent) + +**Description**: Tenant/brand/region-scoped browse/search/filter of published Products/SKUs/Categories with the per-state visibility contract and an `asOfCatalogVersion` staleness signal; version-history retrieval. + +**Breaking Change Policy**: Major version bump for incompatible query/response changes. + +### 9.2 External Integration Contracts + +#### CatalogVersionPublished + registry events (outbound) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-contract-registry-events` + +**Direction**: provided by the registry to the shared event system + +**Protocol/Format**: CloudEvents 1.0 with `dataschema`+semver, correlation/causation, per-aggregate ordering keys, pseudonymous actor refs; includes `CatalogVersionPublished` and the full Product/SKU/Category/Attribute/governance event set (Design owns names/schemas). + +**Compatibility**: `vN` consumer deserializes `vN+1`; bootstrap path (latest `CatalogVersion` + tail); no direct PII. + +#### `SkuReferenceCount` signal (inbound) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-contract-sku-reference-count` + +**Direction**: required from Subscriptions, Contracts, plan-price + +**Protocol/Format**: per-producer watermark ("as of `T`, complete live-reference set is {…}"); freshness on the watermark; registered producers only (Design owns shape). + +**Compatibility**: absence under a fresh watermark ⇒ zero; boolean OR across producers; stale/never-received ⇒ conservatively referenced + alert. **Pre-approval gate**: owner + delivery date (§15). + +#### Freeze acknowledgment (inbound) + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-contract-freeze-ack` + +**Direction**: required from plan-price, Contracts, Billing + +**Protocol/Format**: per-`catalogVersionId` freeze acknowledgment feeding `freezeComplete` (Design owns shape). + +**Compatibility**: bounded timeout fails closed; participant set snapshotted per `catalogVersionId`; force-completion records missing participants as not-frozen. + +#### Bundle composition-completed signal (inbound) + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-contract-bundle-composition-signal` + +**Direction**: required from plan-price + +**Protocol/Format**: signal that a `bundle` SKU has been composed, clearing `compositionPending` (Design owns shape). + +**Compatibility**: clearing produces a new published version and emits `BundleCompositionCompleted`; MUST NOT mutate a prior frozen `CatalogVersion`. + +## 10. Use Cases + +#### Author products and SKUs + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-usecase-product-sku-editor` + +**Actor**: `cpt-cf-bss-products-actor-product-manager` + +**Preconditions**: +- Established tenant/brand/region context; CatalogAdmin or ProductManager role. + +**Main Flow**: +1. Create/select a Product (name, category, description, brand/region scope); `productId`/`skuId` system-generated, `skuCode` operator-entered with inline format check. +2. Add a SKU; pick type product/service/bundle. +3. For a usage SKU, declare the metering unit (validated); set `PlanTier` from the taxonomy. +4. Set tax/GL codes; save as draft (each save bumps the internal revision). + +**Postconditions**: +- A draft Product/SKU exists with reserved codes and an audit entry, pending gated publish. + +**Alternative Flows**: +- **Incomplete at publish**: publish rejected fail-closed (missing required fields / `PlanTier` / accounting codes). + +#### Approve and publish registry changes + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-usecase-approval-publish` + +**Actor**: `cpt-cf-bss-products-actor-finance-reviewer` + +**Preconditions**: +- Pending material change(s) in the approval queue. + +**Main Flow**: +1. Open the pending-approval queue; review the diff and (for `CatalogVersion` publish) the pre-publish lint report. +2. Approve/reject with reason; two-person rule enforced above threshold (≥ 1 FinanceReviewer for finance-material fields). +3. On full sign-off, publish triggers the entity/`CatalogVersion` publish + events. + +**Postconditions**: +- Content published (or returned to draft with reason); approval pinned to the approved revision. + +**Alternative Flows**: +- **Edit after approval**: approval invalidated, change re-queued with diff re-presented. + +#### Deprecate and retire safely + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-usecase-lifecycle-deprecation` + +**Actor**: `cpt-cf-bss-products-actor-catalog-admin` + +**Preconditions**: +- A `published` SKU with (possibly) active references. + +**Main Flow**: +1. Mark `deprecated` (blocks new adoption; existing continue) or un-deprecate (two-person). +2. Initiate retire/EOL; the system shows the active-reference count and requires confirmation. +3. Confirm; snapshots preserved; retirement event emitted with lead-time and optional `replacedBy`. + +**Postconditions**: +- Scheduled transition set; existing references grandfathered on frozen snapshots. + +**Alternative Flows**: +- **Cascade with EOL-requiring children**: those children listed and left un-retired; parent stays non-`retired` with deferred-retire intent tracked. + +#### Browse, search, and inspect history + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-usecase-catalog-browser-history` + +**Actor**: `cpt-cf-bss-products-actor-auditor` + +**Preconditions**: +- Published catalog content and version history exist for the scope. + +**Main Flow**: +1. Filter/search (category, status, brand/region) on the cache-first read model. +2. Open an item; view attributes/classification. +3. Open the version timeline with diffs and audit entries (actor, time, correlation ID). + +**Postconditions**: +- Offerings found; change lineage traced (tenant-scoped). + +**Alternative Flows**: +- **Cross-tenant inspection**: requires time-boxed break-glass (read/audit-export only), individually audited. + +#### Bulk import/export at scale + +- [ ] `p1` - **ID**: `cpt-cf-bss-products-usecase-bulk-operations` + +**Actor**: `cpt-cf-bss-products-actor-catalog-admin` + +**Preconditions**: +- A CSV/JSON batch of Products/SKUs/Categories. + +**Main Flow**: +1. Upload/import; rows land as **draft** with per-row validation and per-row idempotency. +2. Review the aggregated change report (counts, per-type summary, sample, lint findings). +3. Submit the batch for gated approval (two-person on the batch); track per-row success/failure. + +**Postconditions**: +- Rows applied consistently (no orphan/partial publish); a coalesced `CatalogBulkOperationCompleted` emitted. + +**Alternative Flows**: +- **Dependent-row failure**: dependent rows fail with a distinct per-row error; no orphan committed. + +#### Inspect and recover a stuck freeze + +- [ ] `p2` - **ID**: `cpt-cf-bss-products-usecase-freeze-monitoring` + +**Actor**: `cpt-cf-bss-products-actor-catalog-admin` + +**Preconditions**: +- A `CatalogVersion` whose `freezeComplete` has not been reached past the timeout. + +**Main Flow**: +1. View per-`catalogVersionId` `freezeComplete` status and each non-acknowledging participant. +2. Idempotently re-trigger the freeze fan-out. +3. Force-complete under the two-person rule (records missing participants as not-frozen; emits `FreezeForceCompleted`). + +**Postconditions**: +- Freeze resolved or force-completed with missing participants pinned fail-closed for posted use. + +## 11. User Interaction and Design + +| **Interface Name** | **Role** | **Steps** | **Mockup Screen** | +|--------------------|----------|-----------|-------------------| +| Product & SKU editor | As a Product Manager, I define products and SKUs so the catalog foundation is accurate | 1. Create/select Product (name, category, description, scope); `productId`/`skuId` system-generated, `skuCode` operator-entered with format check
2. Add SKU; pick type
3. For usage SKU declare metering unit; set PlanTier
4. Set tax/GL codes
5. Save as draft | — | +| Category & taxonomy manager | As a Product Manager, I organize products into a taxonomy so browse/search and listings are coherent | 1. Open taxonomy tree
2. Create/re-parent Category (uniqueness + cycle checks)
3. Assign products to categories | — | +| Attribute & localization editor | As a Product Manager, I add localized attributes so products read correctly per brand/region | 1. Open attribute editor
2. Add key/value; enable i18n; add locale values
3. Set brand/region visibility; configure fallback | — | +| Catalog approval & publish console | As a Finance Reviewer, I review and approve registry changes before publication | 1. Open pending-approval queue
2. Review diff (+ lint report)
3. Approve/reject with reason; two-person above threshold
4. On sign-off, publish triggers CatalogVersion + events | — | +| Lifecycle & deprecation manager | As a Product Manager, I deprecate and retire SKUs safely so existing references are unaffected | 1. Select published SKU
2. Deprecate / un-deprecate (two-person)
3. Initiate retire/EOL; system shows active-reference count and optional `mustMigrateBy`
4. Confirm; snapshots preserved; retirement event with lead-time | — | +| Catalog browser & version history | As a Partner/Auditor, I browse/search and inspect version history to find offerings and trace changes | 1. Filter/search (category, status, brand/region) on cache-first read model
2. Open item; view attributes/classification
3. Open version timeline with diffs and audit entries | — | +| Bulk operations console | As a CatalogAdmin, I import/export catalog entities in bulk so onboarding and mass edits scale | 1. Upload/import (CSV/JSON); rows land as draft with per-row validation
2. Review aggregated change report (counts, summary, sample, lint)
3. Submit for gated approval; track per-row status
4. Export a deterministic snapshot for a `catalogVersionId` | — | +| Freeze monitoring & recovery console | As an Operator, I inspect and recover stuck cross-module freezes so posting is never blocked silently | 1. View per-`catalogVersionId` `freezeComplete` and non-acknowledging participants
2. Idempotently re-trigger the fan-out
3. Force-complete under two-person (emits `FreezeForceCompleted`) | — | +| Operational console (clone, break-glass, deferred retire) | As an Operator/Platform owner, I want clone, break-glass, and deferred-cascade actions in one place | 1. Clone a Product/SKU (incl. a retired source) to a new draft
2. Break-glass cross-tenant read/audit-export under time-boxed elevation
3. Resume a deferred cascade-retire once blocked children clear | — | + +## 12. Acceptance Criteria + +**As a** Product Manager, Catalog Admin, or Finance Reviewer, **I want** an authoritative, governed, versioned catalog registry **so that** plan/price authoring, subscriptions, contracts, rating, and billing build on stable, reproducible product definitions. + +### Identifiers & Integrity + +**1. Identifier contract** +- **Given** a Product or SKU being created +- **When** the system assigns identifiers +- **Then** `productId`/`skuId` MUST be server-generated immutable identifiers (never operator-supplied); `skuCode` MUST be short, fixed-format, tenant-unique, reserved **atomically at create**, and immutable after first publish +- **And** downstream consumers MUST bind to `skuId`; a reused/malformed `skuCode` MUST be rejected with an audited reason; once first published a `skuCode` is permanently reserved within the tenant +- **And** a Product MAY carry an optional `productCode` under the same rules; when unset, product-level external mapping is `productId`-only + +**2. Product/SKU field-mutability matrix** +- **Given** a published Product or SKU +- **When** an operator edits it +- **Then** mutability MUST be classified by lifecycle state: structural identity immutable (remedied only by retire + clone); `type`/metering-unit immutable-but-correctable via the fresh-zero path; material-but-mutable (`PlanTier`/`taxCategory`/`glCode`/`sellable`) via a new published version under governance; other fields via a new version +- **And** an illegal change MUST be rejected fail-closed with an audited reason +- **And** the active-reference count MUST be sourced from `SkuReferenceCount` as the 3-state predicate; never treat an entity as unreferenced absent a fresh watermark + +**2a. Sellable flag (offering eligibility)** +- **Given** a SKU with `sellable = false` (composition/metering-only; default is `true`) +- **When** it is published and referenced +- **Then** publish MUST succeed and bundle/plan **component** references MUST remain valid, while any **standalone** offer of the SKU MUST fail the plan-price sellability gate (predicate 6) +- **And** flipping `sellable` MUST follow the material-but-mutable path (new published version, governed) and the value MUST be frozen per `CatalogVersion` + +**3. Reference-signal sourcing, freshness & counting** +- **Given** the `SkuReferenceCount` signal (per-producer watermark; owner/delivery date is a pre-approval gate) +- **When** the registry evaluates mutability/correction/retirement +- **Then** absence of a `skuId` under a fresh watermark ⇒ zero for that producer; `referenced` MUST be a boolean OR across registered producers; the registry MUST NOT sum across producers +- **And** a fresh-zero across all producers ⇒ unreferenced; stale ⇒ conservatively referenced + alert; never-received ⇒ conservative + distinct flag +- **And** only registered producers count; Contracts MUST declare whether draft/quote refs count, identically across AC #2/#4/#18; until the signal ships, #2/#4/#18 run fail-safe + +**4. Immutable-field correction (zero-reference & break-glass)** +- **Given** a published SKU whose correctable immutable field (`type` or metering-unit) was set wrong +- **When** a CatalogAdmin requests a correction +- **Then** if `SkuReferenceCount` is fresh-zero across all producers the system MUST allow a governed re-publish (two-person), bump the version, and emit `SkuImmutableFieldCorrected`; absent a fresh-zero signal it MUST reject fail-closed +- **And** while the signal is entirely unavailable, correction MAY proceed only via break-glass (two-person + reason + `SkuCorrectionOverride` recording signal-unavailability), feature-flag OFF by default + +### Product & Taxonomy Definition + +**5. Create a product** +- **Given** a CatalogAdmin/ProductManager in a tenant/brand/region context +- **When** they create a Product (primary category optional at draft, required at publish) +- **Then** the system MUST generate `productId`, persist as `draft` (version 0) with isolation + audit +- **And** name uniqueness MUST hold on `(tenantId, brandId, normalized(name))` within any overlapping region scope; two same-named Products allowed only when region scopes are disjoint +- **And** indeterminate region overlap MUST fail closed with an operator-facing reason; the region-set algebra is a pre-approval gate + +**6. Manage taxonomy (create, rename, re-parent, retire, delete)** +- **Given** a CatalogAdmin managing the taxonomy +- **When** they create/rename/re-parent/retire/delete a Category +- **Then** the system MUST validate uniqueness within parent (re-checked on rename/re-parent), reject cycles, and reject exceeding max depth/children +- **And** a Product carries exactly one primary + zero-or-more secondary categories; the read model MUST make it filterable under every assigned category +- **And** categories are governed live entities (in-place, two-person-gated, audited); retire/delete MUST be blocked while any active Product references it or active children exist; each op emits a `Category*` event + +### SKU Definition & Classification + +**7. Define a SKU** +- **Given** an existing Product +- **When** a ProductManager defines a SKU typed `product`/`service`/`bundle` +- **Then** the system MUST link it, assign `skuId`/`skuCode`, and validate the per-type required-field set +- **And** a `bundle` SKU persists only type flag + identity (composition in plan-price; blocking completeness at `CatalogVersion` publish) +- **And** promotional/$0/"Free" SKUs follow identical registry rules; no separate promo entity + +**8. Declare metering unit (defines a "usage SKU")** +- **Given** a SKU to be metered (declaring a unit is what defines a usage SKU) +- **When** a ProductManager declares its metering unit +- **Then** the system MUST validate against the configured recognized-unit set and reject an unrecognized unit unless elevated approval marks a new validated unit +- **And** a usage SKU declares exactly one unit (single-dimension); multi-dimension via separate SKUs composed at plan/bundle level +- **And** a draft whose unit was `deprecated` before first publish MUST be treated as a new declaration and rejected; the declared unit MUST be carried on publish + +**9. Metering-unit de-listing** +- **Given** a recognized unit referenced by ≥ 1 published SKU +- **When** an operator attempts to de-list it +- **Then** the system MUST reject removal while live references exist and instead support marking it `deprecated` (no new declarations), with full removal only once unreferenced +- **And** a unit's identity/semantics are immutable (no silent GB→GiB); a correction is a new unit + deprecation; de-listing MUST be audited and MUST NOT mutate any frozen snapshot + +**10. PlanTier classification** +- **Given** a SKU being authored +- **When** a ProductManager assigns its `PlanTier` +- **Then** the system MUST validate against the taxonomy and carry it on the published SKU +- **And** a `PlanTier` value has a stable tier code; rename affects the display label only; taxonomy management is governed (two-person), emits `PlanTierUpdated`, and a value MUST NOT be retired while any published SKU carries it; seeded with a neutral value +- **And** `PlanTier` MUST NOT be conflated with OrgTier; plan-publish presence enforcement is delegated to plan-price + +**11. Stable accounting codes on SKU** +- **Given** a SKU +- **When** a ProductManager sets tax-category and GL codes +- **Then** the system MUST persist them as stable codes and validate each against a configured recognized set (owner Finance) +- **And** the codes are required at publish for `product`/`service`-type SKUs; a SKU published without a required code MUST be rejected +- **And** the system MUST NOT compute tax or post to GL (codes only) + +### Attributes & Localization + +**12. Localized attributes, well-known display fields, and definition lifecycle** +- **Given** a Product/SKU with attributes +- **When** a ProductManager adds i18n values with brand/region visibility +- **Then** the system MUST validate against the attribute definition, require the default-locale value, and resolve locale via `(locale, region, brand) → (locale, brand) → (default-locale, brand) → global` +- **And** the registry MUST seed well-known display attribute definitions (localized display name/description for Product/SKU/Category) +- **And** managing definitions is a governed live-entity op emitting `AttributeDefinitionUpdated`; changes MUST be backward-compatible with a deprecate-then-remove lifecycle +- **And** the registry MUST provide an ungoverned, size-bounded, non-localized, search-excluded, PII-prohibited metadata map for machine metadata + +### Versioning, Lifecycle & Deprecation + +**13. Revision vs published version** +- **Given** a Product/SKU +- **When** an operator saves a draft edit +- **Then** the system MUST bump the internal revision (every save) and reject stale-revision writes, while the published version bumps only on publish +- **And** consumers and `CatalogVersion` MUST reference the published version; historical versions retained with a diff, non-modifiable +- **And** version binding MUST be explicit; a bound-but-not-yet-frozen reference re-resolving to a different version at freeze MUST surface a version-change diff, not silently swap + +**14. Lifecycle transitions & reversibility** +- **Given** the state machine `draft → published [↔ deprecated] → retired` (+ `draft → discarded`) +- **When** an operator requests a transition +- **Then** the system MUST allow only defined transitions, treat `retired`/`discarded` as terminal, and allow referencing only for `published`/`deprecated` +- **And** entity publish MAY be scheduled (`publishAt`, UTC) with approval pinned at scheduling and re-validated fail-closed at activation +- **And** there is no `unpublish` and no in-place rollback — retraction/reversion is `deprecate`/`retire` + a new version (forward-only) + +**15. Parent-child (Product↔SKU) lifecycle integrity** +- **Given** the Product↔SKU hierarchy +- **When** an operator publishes or retires across the hierarchy +- **Then** a SKU MUST NOT reach `published` under a non-`published` parent, and MUST NOT be orphaned under a `retired` Product; a SKU's scope MUST be contained within its parent's +- **And** retiring a Product with non-`retired` SKUs MUST require confirmed cascade-retire (partial by design, recording `direct` vs `cascaded`); EOL-requiring children are listed and left un-retired; never-published children auto-`discarded` +- **And** when a partial cascade leaves children, the parent MUST remain non-`retired` with deferred-retire intent tracked/queryable + +**16. Deprecation (governed sub-state)** +- **Given** a published SKU referenced by active plans/subscriptions/contracts +- **When** an operator marks it `deprecated` +- **Then** the system MUST move it to the `deprecated` sub-state, mark it so consumers block new adoption while existing references continue, and emit `SkuDeprecated` +- **And** `deprecated` MUST be a tracked, queryable state recording provenance `direct` (vs `cascaded`) + +**17. Un-deprecation** +- **Given** a `deprecated` SKU +- **When** an authorized operator un-deprecates it +- **Then** `deprecated → published` MUST be allowed under the two-person rule, re-open new adoption, and emit `SkuUndeprecated` +- **And** un-deprecating a Product reverses only `cascaded` child deprecations, never a `direct` one; a `retired` entity MUST NOT be reversible + +**18. Retirement / EOL consumer handoff** +- **Given** a `published`/`deprecated` SKU with active references +- **When** an operator retires it (optionally EOL with `mustMigrateBy`) +- **Then** the system MUST require confirmation with the active-reference count shown, then run a scheduled transition: force `deprecated` at initiation, preserve snapshots, emit `SkuRetired`/`ProductRetired` with `{ skuId, fromVersion, reason, replacedBy?, mustMigrateBy?, effectiveAt }` honoring the ≥ 30-day lead-time, then flip to `retired` at `effectiveAt` +- **And** the registry is SoR for `replacedBy` (a successor published SKU) +- **And** v1 = plain retirement + grandfathering only; EOL-with-`mustMigrateBy` is post-v1, disabled until the subscriptions-lifecycle AC exists and is referenced by number, and requires a consumer ack contract (lapsed ack ⇒ suspend fail-closed + `SkuEolSuspended`) + +### Catalog Versioning & Snapshots + +**19. Publish an immutable catalog version** +- **Given** approved catalog changes +- **When** a CatalogAdmin publishes a `CatalogVersion` +- **Then** the system MUST persist a full snapshot (published Product/SKU set + versions + current categories/attributes), assign a monotonic `catalogVersionId`, generate a checksum, record timestamps, capture the freeze-participant set, and make it immutable +- **And** an uncomposed `bundle` SKU MUST require explicit two-person override (blocking-with-override), recorded in audit, and be flagged `compositionPending = true` +- **And** it MUST emit `CatalogVersionPublished` and expose per-version `freezeComplete`; a published version cannot be withdrawn/rolled back (roll-forward N+1 only); publishes serialize per tenant + +**20. Snapshot reproducibility** +- **Given** a posted invoice or active contract that referenced a `catalogVersionId` +- **When** the catalog later changes +- **Then** re-resolving that `catalogVersionId` MUST yield a byte-identical checksum and unchanged registry content +- **And** `CatalogVersion` MUST be exposable as one component of a downstream `pricingSnapshotRef` without asserting it equals the full snapshot + +**21. Cross-module snapshot freeze atomicity** +- **Given** a `CatalogVersionPublished` consumed by the registered freeze-participants +- **When** a consumer resolves `catalogVersionId` before all have frozen +- **Then** the system MUST expose `freezeComplete` and reject resolution for posted/contractual use until all participants ack, with a bounded timeout that fails closed +- **And** read-only browse MAY proceed during the freeze window +- **And** the resolution API MUST require the consumer to declare intent (`browse` vs `posted/contractual`) so it cannot post against a not-yet-`freezeComplete` version by mislabeling + +**22. Freeze recovery & force-completion** +- **Given** a `CatalogVersion` past the freeze timeout +- **When** an operator inspects it +- **Then** the system MUST identify each non-acknowledging participant, support an idempotent re-trigger, and support force-completion (two-person) that records each missing participant as not-frozen and emits `FreezeForceCompleted` +- **And** force-completion MUST NOT mark missing content as frozen; the default is pinned fail-closed for that participant's content + +**23. Freeze-participant set governance** +- **Given** the set of freeze-participants +- **When** the set changes +- **Then** membership MUST be a governed (two-person), audited change, and each `CatalogVersion` MUST snapshot the participant set at publish time +- **And** a participant removed after publish MUST NOT retroactively flip that version's `freezeComplete` + +**24. Grandfathering invariant** +- **Given** a reference grandfathered onto a frozen snapshot after its SKU is deprecated/retired +- **When** the catalog subsequently changes +- **Then** the registry MUST guarantee the grandfathered snapshot is never mutated +- **And** grandfathering eligibility policy is owned by plan-price / subscriptions-lifecycle; this AC makes the delegation auditable + +**25. Uncomposed-bundle adoption guard** +- **Given** a `bundle` SKU published with the uncomposed override +- **When** the read model and events expose it +- **Then** it MUST carry `compositionPending = true` until composed, and consumers MUST treat it as not-yet-adoptable for new references +- **And** clearing it MUST be driven by a plan-price composition signal, audited, and emitted as `BundleCompositionCompleted` (new version, never mutating a prior frozen `CatalogVersion`) + +### Approval, Publishing & Eventing + +**26. Materiality-gated publish** +- **Given** a Product/SKU change or a material Category/attribute-definition op +- **When** the change is material (touches `PlanTier`/metering-unit/`taxCategory`/`glCode`, a lifecycle transition, a Category create/rename/re-parent/retire/delete, a material attribute-definition change, or exceeds the configured affected-entity count) +- **Then** the system MUST enforce ≥ two distinct approvers, each distinct from the author and holding CatalogAdmin or FinanceReviewer; a finance-material field MUST include ≥ 1 FinanceReviewer +- **And** an approval MUST be pinned to the internal revision; any subsequent edit invalidates it and re-queues with the diff re-presented +- **And** the rule MUST be a typed configurable policy with an enforceable interim default (§17.1); a rejection returns the entity to `draft` with reason; v1 uses a single two-person step + +**27. Idempotent authoring boundary** +- **Given** a retried create/update/publish carrying an idempotency key +- **When** the system processes the retry +- **Then** the same key + identical payload MUST NOT create duplicate entities/versions/events; the key MUST be scoped per tenant + endpoint + client key and retained ≥ 24h and ≥ the max freeze timeout +- **And** reuse with a different payload MUST be rejected as a conflict (no silent no-op) + +**28. Registry eventing & audit** +- **Given** any state-changing registry mutation that completes +- **When** the write commits +- **Then** the registry MUST publish the corresponding CloudEvents 1.0 event onto the shared event system with correlation/causation + idempotency key + ordering keys `(tenant, aggregate)`; every state-changing AC maps to exactly one named event (or an explicit "no event" in Design) +- **And** payloads MUST carry pseudonymous actor references only (never direct operator PII); the mutation MUST be recorded in an immutable, queryable audit trail +- **And** Plan/Price/Bundle-composition events MUST NOT be emitted here (owned by plan-price) + +**29. Event schema versioning & replay** +- **Given** the CloudEvents of AC #28 +- **When** the schema evolves or a consumer must rebuild state +- **Then** every event MUST carry a `dataschema` URI with a semantic version; a consumer pinned to `vN` MUST deserialize `vN+1`; out-of-order/duplicate delivery beyond the idempotency window MUST be detectable via `(tenant, aggregate, sequence)` +- **And** the system MUST provide a bootstrap path (latest `CatalogVersion` + event tail) for published-scope consumers and MUST fail loudly when a consumer checkpoint predates the available event tail + +### Multi-Tenancy & Read Models + +**30. Tenant/brand/region isolation & break-glass** +- **Given** a user scoped to one tenant/brand/region +- **When** they query/mutate outside their scope +- **Then** the system MUST deny by default at the gateway and audit the cross-scope attempt +- **And** privileged cross-tenant access MUST use time-boxed, reason-required, alertable break-glass, itself two-person-approved or post-hoc-reviewed; standing cross-tenant access MUST NOT be granted + +**31. Break-glass action scope** +- **Given** a platform owner under break-glass elevation +- **When** they access a foreign tenant's catalog +- **Then** break-glass MUST permit read and audit-export only; any write/publish MUST be separately gated (two-person + distinct alert) or disallowed in v1 +- **And** every break-glass action MUST be individually audited with the reason and correlation ID + +**32. Cache-first browse/search with bounded convergence** +- **Given** published Products/SKUs/Categories +- **When** a partner/customer browses/searches/filters +- **Then** the system MUST serve from cache-first read models scoped to the caller's tenant/brand/region, converging within its own budget (interim p99 < 2 s) +- **And** stale reads during the window MUST be safe (never expose unpublished/cross-scope content) and carry the `asOfCatalogVersion` staleness signal +- **And** the per-state visibility contract MUST hold: `published` browsable; `deprecated` browsable + flagged + excludable; `retired` excluded from default browse, retrievable via explicit history query + +### Bulk Operations + +**33. Bulk import/export** +- **Given** a CatalogAdmin importing/exporting in bulk +- **When** the batch is processed +- **Then** the system MUST apply per-row idempotency, report per-row success/failure (no hidden partial failure), and never leave a partially-inconsistent published state +- **And** dependent rows MUST apply two-phase or dependency-ordered, never committing an orphan; idempotency operates at batch + per-row levels; a coalesced `CatalogBulkOperationCompleted` is emitted (no event storm) +- **And** bulk import lands entities in `draft`; publication remains gated, approved against an aggregated change report; export MUST be deterministic for a given `catalogVersionId` + +### Cloning + +**34. Clone a product/SKU** +- **Given** a source Product/SKU (draft, published, or retired — the sanctioned revival path) +- **When** a ProductManager clones it +- **Then** the system MUST create a new `draft` with new `productId`/`skuId` and a new `skuCode`/optional `productCode`, copying structure/attributes/scoping/category/`PlanTier`/metering-unit, resetting lifecycle and version counters, and never copying pricing/plan content +- **And** the cloned metering unit, `PlanTier`, and category assignment MUST be re-validated against live registries; the clone MUST fail or force re-selection if any was de-listed/deprecated/retired; a `clonedFrom` reference is recorded and the source unaffected + +### Data Retention & Erasure + +**35. Retention & right-to-erasure** +- **Given** retired entities, historical versions, and audit records +- **When** a retention or erasure (GDPR/CCPA) request applies +- **Then** the system MUST retain financial/version/audit records for the configured duration and satisfy erasure of actor PII by pseudonymizing it across audit, entity version fields, and the actor identity-reference map — not deleting immutable records +- **And** attribute/description free-text MUST NOT contain personal data — enforced by a validation block at write (hard prohibition, no carve-out, fail-closed on uncertainty, curated allow-list); Legal sign-off recorded in the approval artifact +- **And** erasure MUST NOT break `CatalogVersion` reproducibility or audit completeness + +### Cross-PRD Consistency + +**36. Registry ↔ plan-price seam** +- **Given** the shared contract on `skuId`, `bundle` type, metering-unit declaration, `PlanTier`, and `CatalogVersion` +- **When** registry or plan-price schemas change +- **Then** there MUST be a shared schema-version pin and a CI contract test that fails on divergence; a runtime divergence MUST fail closed (reject the dependent plan publish) +- **And** the same suite MUST assert consumer-side obligations: reject adoption of `compositionPending`/`deprecated` SKUs; reject a usage binding with no declared unit (and reject/warn on a `deprecated` unit) — where usage-completeness is enforced; consume `mustMigrateBy` (post-v1); resolve grandfathered refs against the frozen snapshot; re-validate on `SkuImmutableFieldCorrected`; declare intent before `freezeComplete` +- **And** each consumer-side assertion is authorable only once the referenced counterpart AC exists; (d) `mustMigrateBy` is deferred with the post-v1 EOL capability + +**37. Monetization-model traceability** +- **Given** the deliberate decision that the registry carries no monetization-model marker (only `usage` leaves a footprint) +- **When** a reader asks which models are supported and where +- **Then** the PRD MUST expose a traceability map (§17.2): flat/per-seat/tiered/volume/hybrid/commitment → plan-price + Tariffs; usage → metering-unit declaration here + binding/rating downstream +- **And** absence of a model marker on a SKU MUST be treated as intentional, not a missing field + +### Error & Negative Paths + +**38. Expected failure behavior** +- **Given** an invalid or conflicting authoring request +- **When** the system processes it +- **Then** it MUST fail closed with an audited reason and MUST NOT partially apply, for each of the enumerated cases (see the `expected-failure-behavior` FR): stale-revision write, duplicate idempotency key with a different body, taxonomy cycle, unrecognized unit without elevation, publish of an incomplete entity, immutable-field change without a valid correction path, reissue/collision of a reserved `skuCode`, EOL without an acknowledged migration consumer (post-v1), SKU under a non-`published` parent, SKU scope outside its parent, authoring/cloning against a de-listed/deprecated unit, a bulk row whose in-batch dependency failed, adopting a `compositionPending` bundle, an indeterminate region overlap, and a retention process that would orphan a live grandfathered reference + +### Operational Resilience & Concurrency + +**39. Event delivery resilience** +- **Given** a registry event fanned out to ≥ 1 consumer +- **When** a delivery fails, a consumer never acks, or an event is poison +- **Then** the shared event system MUST provide bounded-backoff retry, per-consumer delivery state, and an audited dead-letter path with alerting (transport owned there) +- **And** the registry's own obligations are: not reporting emission success until durably accepted, surfacing per-consumer delivery/DLQ state as a projection, and never mutating registry state on delivery failure; during a bus outage mutations MAY commit with events to a durable outbox, with the propagation clock starting at durable bus acceptance + +**40. CatalogVersion publish concurrency** +- **Given** two staged sets of changes targeting publication within one tenant +- **When** publishes are submitted concurrently, or a publish races a `deprecate`/`retire` on an entity it enumerates +- **Then** publishes MUST serialize per tenant, `catalogVersionId` MUST be allocated monotonically without gaps/collisions, and a staged entity mutated/retired between stage and publish MUST cause that publish to re-validate fail-closed (rejected, naming the changed entity) + +**41. Fail-safe duration tripwire** +- **Given** the registry operating in `SkuReferenceCount`-unavailable fail-safe mode +- **When** break-glass immutable-field corrections exceed the configured rate (interim > 5 in 30 days) +- **Then** the system MUST raise an escalation alert and reclassify `SkuReferenceCount` delivery as a release blocker, so degraded operation is escalated, not normalized + +**42. `skuCode` reservation concurrency** +- **Given** two concurrent create/reserve requests for the same `skuCode` within one tenant +- **When** both are processed +- **Then** the system MUST atomically reserve at create, admit exactly one, and reject the other fail-closed with an audited reason; a `draft` reservation MUST block a second draft until released/discarded +- **And** a `skuCode` changed while still `draft` MUST release the previous code; discarding a never-published draft MUST also release its `skuCode`/`productCode` reservation + +**43. Reference-producer registration** +- **Given** the set of `SkuReferenceCount` producers (Subscriptions, Contracts, plan-price) +- **When** the registry evaluates `referenced` +- **Then** only registered producers' signals or silence MUST factor in; an unregistered producer's absence MUST NOT pin SKUs conservatively-referenced; membership MUST be a governed, audited change snapshotted symmetrically with the freeze-participant set +- **And** onboarding a new producer MUST NOT retroactively flip historical mutability/retirement decisions + +**44. Grandfathered-snapshot retention coupling** +- **Given** a `catalogVersionId` referenced by ≥ 1 live grandfathered reference +- **When** retention/erasure would expire, tier, or GC that snapshot +- **Then** the snapshot MUST remain byte-identically resolvable for as long as a live reference exists, regardless of the statutory-max clock; retention expiry MUST be gated on no live references to that `catalogVersionId` +- **And** version-liveness MUST be sourced from per-version freeze-registration records (or a `(catalogVersionId, producer)` contract), never the SKU-level count alone; a process that would orphan a live reference MUST fail closed with an alert + +**45. Pre-publish lint report** +- **Given** the `validate(lint)` operation before `CatalogVersion` publish +- **When** an admin runs it (or publish triggers it) +- **Then** the lint MUST return a structured, per-entity report of every override-requiring/attention condition (uncomposed bundles, missing default-locale attribute values, declarations against a `deprecated` unit) so the two-person override is informed and the audit records what was overridden + +### Non-Functional Requirements (Show-Stoppers) + +**1. Read latency** +- **Given** a warm read model holding 10K SKUs/tenant with ≥ 100 concurrent readers +- **When** browse/search reads execute within a tenant partition +- **Then** p95 latency MUST be < 100 ms over a 5-minute window, sustained via cache-first read models and partitioning + +**2. Read throughput** +- **Given** the cache-first read model under load +- **When** browse/search traffic peaks +- **Then** the system MUST sustain ≥ 2,000 read QPS per tenant partition at the AC-1 latency target + +**3. Publication propagation** +- **Given** an approved publish +- **When** the publish completes +- **Then** downstream event availability (incl. fan-out) MUST occur within < 3 s — distinct from read-model convergence (< 2 s) and the end-to-end posting-safe budget (< 5 s) + +**4. End-to-end posting-safe budget** +- **Given** a write commit that must become safe for Contracts/Billing to post against +- **When** measured from commit to "posting-safe" (read converged AND all `freezeComplete` acks) +- **Then** the composite MUST be p99 < 5 s; if the freeze times out the version MUST remain non-posting-safe (fail closed) + +**5. Snapshot archival & cold-resolution SLA** +- **Given** accumulating immutable snapshots under statutory-bounded retention at ≥ 10K SKUs/tenant +- **When** an archived ("cold") `catalogVersionId` is re-resolved +- **Then** re-resolution MUST remain byte-identical and meet a looser-than-hot target (interim p95 < 2 s) +- **And** snapshots + version history are financial records with a durability class (≥ 11 nines / replicated), periodic restore verification, and a cross-region/DR posture with RPO/RTO + +**6. Scale & extensibility limits** +- **Given** a large tenant +- **When** the catalog grows +- **Then** the system MUST support ≥ 10K SKUs/tenant without breaching read latency, within configured extensibility limits, and MUST bound tenant count, cardinality, and `CatalogVersion` growth + +**7. Graceful degradation & staleness exposure** +- **Given** read load above the throughput ceiling or read-model lag above budget +- **When** the system serves browse/search +- **Then** it MUST shed/queue excess load without ever serving cross-scope or unpublished content, and MUST expose staleness via the same machine-readable `asOfCatalogVersion` signal (no silently-stale response) + +**8. Determinism & integrity** +- **Given** the registry invariants +- **When** authoring/publish runs +- **Then** version immutability, taxonomy acyclicity, SKU identity uniqueness, and metering-unit validity MUST be enforced fail-closed, and posted-period snapshots MUST remain immutable + +**9. Backward-compatible schema evolution** +- **Given** a consumer pinned to schema `vN` +- **When** the registry publishes a `vN+1` payload +- **Then** the consumer MUST deserialize it (new fields optional with defaults); a CI contract test MUST assert backward compatibility on every schema change + +**10. Availability & audit completeness** +- **Given** the catalog service +- **When** measured over the SLO window +- **Then** the cache-first read path MUST meet 99.9% availability and the write/publish path 99.5%; write paths MUST be fully audited even during partial failures + +## 13. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| Tenant identity & hierarchy (OSS/AMS + IdP) | `tenantId`, brand/region claims, OrgTier projection targets, role claims (registry MUST NOT mutate tenant topology) | `p1` | +| Plan & Price Modeling | Consumes published SKU identity/type, metering-unit declaration, `PlanTier`, `CatalogVersion`; produces `SkuReferenceCount`, `freezeComplete` ack, bundle composition-completed signal | `p1` | +| Tariffs / Pricing Logic | Consumes published SKU refs + `CatalogVersion` (price resolution there) | `p1` | +| Rating & Charging | Consumes metering-unit declaration + published SKU refs | `p1` | +| OSS metering | Emits usage values (external); consumes the metering-unit declaration | `p1` | +| Subscriptions (lifecycle & entitlements) | Produces `SkuReferenceCount`; consumes SKU refs + `PlanTier` + `replacedBy` + `mustMigrateBy` (post-v1); owns live-subscription migration | `p1` | +| Contracts & Agreements | Produces `SkuReferenceCount` (incl. draft/quote refs per contract) + `freezeComplete` ack; consumes `CatalogVersion` snapshots for quotes | `p1` | +| Billing & Invoicing | Produces `freezeComplete` ack; consumes SKU refs + `CatalogVersion` (descriptors authored in plan-price, frozen into `CatalogVersion`) | `p1` | +| Marketplace & Vendor Portal | References published SKUs; vendor ops remain in the Marketplace PRD (§4.8) | `p2` | +| Presentation / Portals | Consumes catalog read models for browse/search cache warming | `p2` | +| Events & Audit (Common Core) | Shared event system: durable acceptance, per-consumer delivery/DLQ state, retry; transport owned there | `p1` | +| BSS Architecture Manifest | §4.1 (registry), §4.4 (posting immutability), §4.2/§4.3/§4.6/§4.8 (consumers), §2.1.3, §7.2 | `p1` | + +> **Registry is upstream of all commercial modeling.** A SKU MUST be published before a Plan/Price can reference it. The registry MUST NOT require any downstream consumer to re-interpret mutable catalog state for **posted** periods; the `CatalogVersion` snapshot contract is authoritative (manifest §4.4). + +## 14. Assumptions + +- The `SkuReferenceCount` signal will be delivered by Subscriptions, Contracts, and plan-price with a committed owner + date (pre-approval gate); until then AC #2/#4/#18 run fail-safe, bounded by the break-glass path and the fail-safe tripwire. +- Interim configurable-policy defaults (§17.1) are enforceable at launch; each final value is owned by another function and changes are governed/audited. +- Numeric NFR targets are binding **design targets** until the NFR workshop (within 2 weeks of approval; DRI = BSS Program Lead). +- The shared event system (Common Core) provides ordering/at-least-once/DLQ transport; the registry states only its own emission/projection obligations. +- Recognized-set owners (metering units → Product + Rating; tax/GL codes → Finance; `PlanTier` → Product) seed and govern their sets; the registry validates against them. +- `PRD-plan-price-modeling` and this PRD stay consistent on the shared fields via a CI seam test; the combined predecessor PRD is refactored to Marketplace-only after approval. + +## 15. Open Questions + +> **Pre-approval gates (must be closed at approval).** Two items MUST carry a committed owner/decision **before** sign-off because they govern launch behavior: (1) **`SkuReferenceCount` owner + delivery date** — until committed, AC #2/#18 run fail-safe; (2) **Region-set algebra** for AC #5 overlap semantics. Event delivery resilience and `CatalogVersion` publish concurrency are build conditions captured as FRs/ACs. + +| **Question** | **Answer** | **Date Answered** | +|--------------|------------|-------------------| +| **GATE — `SkuReferenceCount` signal owner + delivery date** | Contract + counting defined; owner sign-off + committed delivery date MUST be recorded at approval. Until it ships, AC #2/#18 run fail-safe (bounded by break-glass + tripwire). *(Owner: Architecture + Subscriptions/Contracts.)* | TBD (gate) | +| **GATE — Region-set algebra** (containment/wildcard/disjointness for AC #5) | Interim conservative rule live (non-disjoint ⇒ overlapping, fail-closed); exact algebra pinned in Design before implementation. *(Owner: Design + Product.)* | TBD (gate) | +| Contracts draft/quote references: count toward `referenced`? + re-resolve-at-freeze behavior | Producer contract must declare both, identical across AC #2/#4/#18; recorded at sign-off. *(Owner: Contracts + Architecture.)* | TBD | +| EOL `mustMigrateBy`: pull into v1 or confirm the post-v1 deferral? | Registry side deferred; needs a date to pull in or confirmation. Gates EOL child cascade. *(Owner: Subscriptions.)* | TBD | +| Finance materiality threshold production value + date | Dimension + interim default resolved; needs a committed production value + date. *(Owner: Finance.)* | TBD | +| Legal content-PII prohibition sign-off | Normative position = hard prohibition, no carve-out; Legal to confirm sufficiency + detector posture, recorded at approval. *(Owner: Legal.)* | TBD | +| Data retention durations per record class + PII pseudonymization age | Interim set (financial/version/audit → statutory max). Final durations per jurisdiction. *(Owner: Legal/Finance.)* | TBD | +| Recognized metering-unit set owner + add-unit / de-list workflow | Interim seed ships; de-list governed. Owner + workflow. *(Owner: Product + Rating.)* | TBD | +| Recognized tax-category / GL-code sets: owner + add / de-list workflow | Interim configured sets ship; validated + required at publish for product/service types. Owner + workflow. *(Owner: Finance.)* | TBD | +| PlanTier taxonomy governance ownership confirmation | Taxonomy + SKU value owned here; plan enforces presence at plan publish. Confirm with plan-price. *(Owner: Product + plan-price.)* | TBD | +| Catalog taxonomy/category scheme (IaaS/PaaS/SaaS/…) | To be defined. *(Owner: Product.)* | TBD | +| Media/binary asset ownership (does the registry hold asset URIs?) | Out of scope as binaries; registry may carry URIs. Confirm owner — Presentation / Marketplace / DAM. *(Owner: Product + Presentation/Marketplace.)* | TBD | +| Catalog-relationship block beyond `bundle`/parent-child/`replacedBy` | Registry owns `replacedBy`/supersedes; other types out of scope v1. Confirm need vs plan-price/Subscriptions. *(Owner: Product + Architecture.)* | TBD | +| Per-seat monetization: truly zero registry footprint? | Confirm no seat-as-unit artifact; metered seats would collide with the single-unit rule. *(Owner: Product.)* | TBD | +| Monetization-model coverage: plan-price pointer accepted in lieu of registry fields? | Traceability map provided (§17.2). *(Owner: Finance/Program.)* | TBD | +| Event-bus transport contract owner + home (ordering, at-least-once, DLQ retention) | Registry states its requirements; the contract is owned by Common Core / Events & Audit. *(Owner: Architecture/Eng — Common Core.)* | TBD | +| Event-log retention/TTL value | MUST be ≥ the bootstrap gap implied by AC #29. *(Owner: Eng/Common Core.)* | TBD | +| `CatalogVersion` archival economics: storage growth + publishes/day/tenant target | Tiering allowed while byte-identical; needs the publish-frequency target before storage design. *(Owner: Eng/Finance.)* | TBD | +| Snapshot durability / DR targets (RPO/RTO + restore-verification cadence) | Snapshots are financial records: interim ≥ 11 nines + periodic checksum restore verification; RPO/RTO ratified at the NFR workshop. *(Owner: Eng/Program.)* | TBD | +| Snapshot-GC version-liveness source | Per-SKU count has no version dimension; source from per-version freeze-registration or a `(catalogVersionId, producer)` contract. Confirm. *(Owner: Architecture + freeze participants.)* | TBD | +| Cross-PRD seam contract-suite owner + repo/pipeline | Proposed: BSS Catalog/Architecture in `api-contracts` CI. Final owner sign-off. *(Owner: needs assignment.)* | TBD | +| Disposition of `PRD-product-catalog-marketplace-202601120119` (refactor to Marketplace-only) | Keep now; refactor to §4.8-only after this + plan-price + Tariffs approved. *(Owner: Product/Program.)* | TBD | +| NFR workshop: named DRI, held within 2 weeks of approval, SLO table ratified | Targets are binding design targets until then. *(Owner: BSS Program Lead.)* | TBD | + +## 16. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `SkuReferenceCount` signal slips | AC #2/#4/#18 stuck in fail-safe (no immutable-field correction on referenced SKUs) | Pre-approval gate on owner + date; break-glass bounds per-operation debt; the fail-safe tripwire (> 5/30d) bounds it in time | +| Region-set algebra undefined | False name collisions/allows on create (AC #5) | Interim conservative fail-closed rule; pin the algebra in Design before implementation (gate) | +| Snapshot lost/corrupted | Breaks byte-identical reproducibility for every contract frozen to it — a compliance event | Snapshots as financial records: ≥ 11 nines durability, periodic checksum restore verification, retention gated on live references (AC #44/#55) | +| Registry ↔ plan-price schema drift | Silent divergence breaks downstream binding/posting | Shared schema pin + CI seam contract test that fails closed (AC #36/#48) | +| Stuck cross-module freeze | Posting blocked or unsafe | `freezeComplete` fail-closed + idempotent re-trigger + governed force-completion (AC #21/#22) | +| Full-snapshot-per-publish cost growth | `CatalogVersion` storage/economics at 10K+ SKUs × frequent publishes | Batching-as-policy; publishes/day/tenant target + archival economics at the NFR workshop (AC #44/#45) | +| Combined predecessor PRD left authoritative for registry | Duplicate/divergent catalog requirements | Refactor `PRD-product-catalog-marketplace` to Marketplace-only after approval (§15) | + +## 17. Reference Materials + +| **Material** | **Link** | **Comments** | +|--------------|----------|--------------| +| BSS Architecture Manifest | `docs/bss/manifest/vz-arch-manifest-bss-only.md` | §4.1 (registry) incl. the Decomposition (BSS realization) note = normative home of the §4.1 split; §4.4 posting immutability; §4.2/§4.3/§4.6/§4.8 consumers; §2.1.3; §7.2 events | +| Plan & Price Modeling | `docs/bss/prd/PRD-plan-price-modeling-202605281200/PRD-plan-price-modeling-202605281200.md` | Owns Plan/Price/PriceWindow/PriceList/Bundle composition/add-ons/billing descriptors/plan lifecycle; builds on this registry | +| Rating (evaluation core) | `gears/bss/rating/docs/PRD.md` | Price evaluation over the primitives plan-price authors | +| Rating (pipeline) | `gears/bss/rating/docs/PRD.md` | Consumer of metering-unit declaration and published SKU refs | +| Subscriptions — Lifecycle | `docs/bss/prd/PRD-subscriptions-lifecycle-202604021200/PRD-subscriptions-lifecycle-202604021200.md` | Consumes SKU refs + PlanTier + CatalogVersion; owns live-subscription migration | +| Product Catalog & Marketplace (predecessor) | `docs/bss/prd/PRD-product-catalog-marketplace-202601120119/PRD-product-catalog-marketplace-202601120119.md` | Combined §4.1+§4.8 predecessor; registry scope superseded here, Marketplace retained there | +| Project glossary | `docs/project-glossary.md` | Canonical terms | +| Trace chain | `AGENTS.md` (repository root) | Manifest → PRD → ADR → Design → Stories | + +### 17.1 Configurable-Policy Interim Defaults + +| Policy | Interim default (fail-safe) | Final owner | +|--------|-----------------------------|-------------| +| Materiality threshold | Two-person rule on any material-field change (always); affected-entity-count trigger ≥ 10; single-entity non-material change passes with one approver | Finance | +| Recognized tax-category & GL-code sets | Configured enum + GL chart; unknown codes rejected at authoring; new codes require elevated approval; de-list blocked while referenced; required at publish for product/service types | Finance | +| PlanTier taxonomy seed | Seeded with a neutral value (`standard`/`none`) + operator-defined tiers; tier identity is a stable code, rename = display-only | Product | +| Idempotency-key retention | ≥ 24h and ≥ the maximum freeze timeout | Eng/Design | +| `SkuReferenceCount` freshness threshold | 15 min; staler → conservative + alert | Architecture | +| Fail-safe break-glass tripwire | > 5 break-glass corrections / 30 days → escalate + signal delivery becomes release blocker | Architecture | +| Retirement / EOL lead-time | ≥ 30 days between event and effective hide | Subscriptions + Product | +| Recognized metering-unit set | Seeded with platform base units (`vCPU-hours`, `GB-storage`, `GB-egress`, `request-count`); new units require elevated approval; de-list blocked while referenced | Product + Rating | +| Retention — financial/version/audit | Retain to statutory maximum (not "indefinite") | Legal/Finance | +| Retention — operator PII | Pseudonymize at erasure request or a defined max age, whichever first | Legal/Finance | +| Read-model convergence | p99 < 2 s after write commit | Eng | +| Event propagation / fan-out | p99 < 3 s after publish | Eng | +| End-to-end posting-safe budget | p99 < 5 s (read converged and `freezeComplete`) | Eng | +| Cold `catalogVersionId` resolution | p95 < 2 s (looser than hot reads) | Eng | +| Snapshot durability & DR / RPO-RTO | ≥ 11 nines / replicated storage; periodic restore verification; RPO/RTO at the NFR workshop | Eng/Program | +| Numeric NFRs | Binding design targets until the NFR workshop (approval + 2 weeks); DRI = BSS Program Lead | Program/Eng | + +### 17.2 Monetization-Model Traceability + +| Monetization model | Where authored / evaluated | +|--------------------|----------------------------| +| flat, per-seat, tiered, volume, hybrid, commitment | `PRD-plan-price-modeling-202605281200` (authoring) + `PRD-tariffs-pricing-logic-202604011200` (evaluation) | +| usage | Metering-unit **declaration** here (registry) + plan-level meter binding (plan-price) + rating (Rating) | + +Absence of a monetization-model marker on a SKU is **intentional**, not a missing field. + +--- + +*Child artifacts: ADR(s) for versioning/snapshot strategy and lifecycle/deprecation modeling; the gear's DESIGN (`gears/bss/products/docs/DESIGN.md`, pending) for entity schemas, APIs, events, and read-model design; STORY documents per scope item. The §4.1 registry↔commercial decomposition is recorded in the manifest §4.1 Decomposition (BSS realization) note, not a separate ADR.* + diff --git a/gears/bss/rating/docs/ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md b/gears/bss/rating/docs/ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md new file mode 100644 index 000000000..a6c2d1fff --- /dev/null +++ b/gears/bss/rating/docs/ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md @@ -0,0 +1,101 @@ +--- +status: accepted +date: 2026-07-10 +decision-makers: "BSS Rating team" +--- + +# ADR-0001: Adopt the Pricing Canonical Scope Key (Do Not Define a Tariffs Key) + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Keep the 4-axis key with post-selection filters](#keep-the-4-axis-key-with-post-selection-filters) + - [Define a Tariffs-local key](#define-a-tariffs-local-key) + - [Adopt the pricing 8-axis canonical key (chosen)](#adopt-the-pricing-8-axis-canonical-key-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-rating-adr-scope-key-adoption` + +## Context and Problem Statement + +The Tariffs PRD originally selected `Price`/`PriceWindow` at step 2 on a 4-axis tuple +`(planId, currency, region, phase)` and asserted "at most one window matches". The pricing gear is +the ratified System of Record for the scope key and **deliberately publishes many concurrently +active rows** on that shorter tuple: a hybrid plan holds `recurring` **and** `usage` rows (differing +only in `chargeKind`), and grandfathering keeps N `cohort` generations live alongside the successor +— all active at one `t` on one `(planId, currency, region, phase)`. + +Under the 4-axis selection those are multiple matches; "at most one window matches" therefore +**fails-closed on exactly the legitimate catalogs the pricing gear engineered**. What key must +Tariffs use so that selection is unique without banning hybrid / grandfathered / phased plans? + +## Decision Drivers + +* Selection and non-overlap must key off **one** identity, used identically by the catalog and by Tariffs — divergence re-introduces the collisions the pricing ADRs removed. +* Hybrid `chargeKind` rows and multiple grandfathering `cohort` generations must be **distinct** identities, disambiguated rather than rejected. +* Tariffs is an evaluation consumer, not the scope-key SoR; the pricing gear owns the key (`ADR/0001`, `ADR/0002`) and binds Tariffs to it as a cross-team contract. +* No new store: generation selection must ride an input Tariffs already has. + +## Considered Options + +1. Keep the 4-axis key with post-selection `priceEligibility` filters. +2. Define a Tariffs-local key. +3. Adopt the pricing 8-axis canonical key verbatim (chosen). + +## Decision Outcome + +Chosen option: **Adopt the pricing 8-axis canonical scope key verbatim** — +`(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` — for both +selection and the non-overlap invariant. `phase` is a `phase_id` (uuid). Within +`existing_grandfathered`, the generation is selected by the `cohort` of the subscription's **pinned +price id** in `pricingSnapshotRef`, never by `activatedAt` alone. Eligibility classes order +`existing_grandfathered > new_subscriptions_only > all_subscriptions`. + +### Consequences + +* "At most one window matches" holds **on the full key**; coexisting `chargeKind` / `cohort` rows are disambiguated, not rejected. +* Tariffs carries no independent key definition and cannot drift from the SoR (guarded jointly with the pricing gear). +* Multi-generation grandfathering needs no new store — the pin already exists in `pricingSnapshotRef`. + +### Confirmation + +* A joint fixture: a hybrid plan (`recurring` + `usage`) and a grandfathered plan with ≥ 2 `cohort` generations both resolve to exactly one row per line without a non-overlap failure. +* The Tariffs step-2 selection key and the pricing gear's `pricing_price` uniqueness/non-overlap key are byte-identical in the shared fixture set. + +## Pros and Cons of the Options + +### Keep the 4-axis key with post-selection filters + +* Good: minimal change to the incoming PRD. +* Bad: non-unique selection on hybrid and multi-generation catalogs; fails-closed on legitimate rows; grandfathering by `activatedAt` cannot disambiguate coexisting generations. + +### Define a Tariffs-local key + +* Good: self-contained. +* Bad: guaranteed drift from the pricing SoR; contradicts the cross-team contract in pricing `ADR/0001`/`ADR/0002`; duplicates an identity the catalog already owns. + +### Adopt the pricing 8-axis canonical key (chosen) + +* Good: unique selection; no drift; no new store; honors the ratified cross-gear contract. +* Bad: Tariffs must consume `priceEligibility`, `chargeKind`, and `cohort` from the frozen snapshot — a bounded contract addition, already available via the pinned price id. + +## More Information + +Cross-gear seam analysis (seams K1-K5), rationale, and the ownership matrix are in +[`../SEAMS.md`](../SEAMS.md). Pricing-side ADRs: `cpt-cf-bss-pricing-adr-canonical-scope-key`, +`cpt-cf-bss-pricing-adr-grandfathering-cohort-axis`. + +## Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §1.4 (`PriceWindow`, `Price eligibility`), §6.3 (step 2), §6.5. +- **Seams**: [`../SEAMS.md`](../SEAMS.md) K1-K5. +- **Design**: [`../design/02-selection-eligibility.md`](../design/02-selection-eligibility.md). diff --git a/gears/bss/rating/docs/ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md b/gears/bss/rating/docs/ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md new file mode 100644 index 000000000..992381774 --- /dev/null +++ b/gears/bss/rating/docs/ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md @@ -0,0 +1,232 @@ +--- +status: accepted +date: 2026-07-11 +decision-makers: "BSS Rating/Tariffs owner (single owner for both parts)" +--- + +# ADR-0002: One `rating` Gear — Consolidate Tariffs (Evaluation Core) and the Rating Pipeline + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Target structure](#target-structure) + - [What lands where (scope split)](#what-lands-where-scope-split) + - [Naming table](#naming-table) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Migration Plan (impact map)](#migration-plan-impact-map) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Keep two gears, fix wording only](#keep-two-gears-fix-wording-only) + - [Rename `tariffs` → `rating-core`, keep two gears](#rename-tariffs--rating-core-keep-two-gears) + - [One `rating` gear (chosen)](#one-rating-gear-chosen) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-rating-adr-rating-gear-consolidation` +*(this ADR's own ID migrates to the `rating` prefix in the mechanical rename commit, like every +other tariffs id).* + +## Context and Problem Statement + +The domain currently splits classic **rating** across two gears with misleading names: + +- The gear named **Tariffs** owns no tariffs. Tariff-as-data (resource @ price, models, windows, + price overlays) is the **Pricing** gear's SoR — the seam analysis (SEAMS, T-D-01…T-D-08) moved + every definition-ownership claim there under *adopt, don't fork*. What the gear actually owns + is the **pure price-decision function** (steps 1–9, determinism, snapshot composition) — in + classical BSS taxonomy, *rating proper*. +- The gear named **Rating** is the operational half — usage ingestion, windowed `Q` aggregation + (mediation), dedup, persistence, orchestration — and its PRD + (`PRD-rating-engine-202604031200`, VHP-810) is **draft/empty**; its high-scope items ("tiered + pricing evaluation", "deterministic outputs + pricingSnapshotRef") were explicitly migrated + into the Tariffs PRD (§2.2). +- Physically there is already **one deployable**: PLAL is normatively a logical module inside the + BSS Rating deployable (manifest §4.2; Tariffs PRD §2). + +The 2026-07-11 tri-review exposed the split as load-bearing debt: money-affecting duties had to be +assigned to the neighbour gear as **cross-PRD obligations against an empty PRD** — the period tick +(T-D-15), delta-dedup ownership (T-D-11), `CommitmentBalanceEffect` publication (T-D-10) — and the +Rating handoff stayed "intent-stable but schema-unstable" because its ratifying counterpart does +not exist (review finding C6). Two gears, one deployable, one empty PRD, and a gear name that +contradicts its content: what should the domain structure and naming be? + +## Decision Drivers + +* A gear name must encode **responsibility** (what it owns), not the subject vocabulary; "tariff" + correctly names Pricing's rate definitions, not this gear's evaluation function. +* The **pure-core boundary** (determinism, byte-identical replay, frozen inputs) is the design's + crown jewel and must survive any restructuring — preferably with *stronger* enforcement than a + documentation split. +* Cross-PRD obligations between the two halves are really **internal design decisions** of one + domain; routing them through seam machinery adds cost and no protection. +* Cost window: the gear has **no code yet** (docs only) and Design lock has not happened — rename + cost is at its lifetime minimum. +* Conway check passed: both halves have the **same owner** (confirmed 2026-07-11), so one gear + does not create split ownership. + +## Considered Options + +1. Keep two gears as-is (`tariffs` + `rating`), fix only the PRD's "owns" wording. +2. Rename `tariffs` → `rating-core`, keep two gears (`rating-core` + `rating`). +3. **One `rating` gear**: evaluation core as an isolated no-I/O crate + pipeline slices absorbing + the VHP-810 scope (chosen). + +## Decision Outcome + +Chosen option: **one `rating` gear**. The entire rating domain — the pure evaluation core +(the current Tariffs design set) and the operational pipeline (mediation, `Q`, dedup, +persistence, period tick) — lives in `gears/bss/rating/` as one gear with one PRD and one design +set. The pure-core boundary moves from a gear boundary to a **crate boundary**: `rating-core` is +a dependency-clean crate (no I/O deps — no tokio/sqlx/http; enforced by the Cargo dependency +graph plus a CI deny-list) exercised by golden fixtures in isolation. The word **"tariff" +returns to the Pricing vocabulary** (rate definitions / rate card) and stops naming this gear. + +### Target structure + +```text +gears/bss/rating/ +├── docs/ +│ ├── PRD.md ← current Tariffs PRD, retitled; §2.2 absorbs VHP-810 scope +│ ├── DESIGN.md ← index regrouped: core slices + pipeline slices +│ ├── DECISIONS.md, SEAMS.md ← retitled Rating ⇄ Pricing +│ ├── ADR/ +│ └── design/ +│ ├── 01–11 … ← evaluation-core slices (this design set, ~as-is) +│ └── 12–16 … ← pipeline slices (new; content = the duties today assigned +│ to the "Rating" actor + T-D-10/11/15) +├── rating-core/ ← (when code starts) pure crate: evaluate()/reresolve(), +│ steps 1–9, guards; zero I/O dependencies +└── … ← pipeline crates per ToolKit gear layout +``` + +Pipeline slice set (initial): **12** usage ingestion & normalization; **13** windowed `Q` store +(single-writer, per-slice attribution + `bandOffsetQ`) & usage/delta dedup (T-D-11); +**14** evaluation-unit synthesis & the period tick (T-D-15) & context assembly / read-model pin; +**15** rated-output persistence & the RatedCharge/BillableItem mapping (slice 11 §4.1 table) & +`CommitmentBalanceEffect` publication + cascade orchestration (T-D-10); +**16** Billing handoff & operations/scale. + +### What lands where (scope split) + +| Current content | Destination | +|---|---| +| PRD §6 semantics; slices 01–07 (pipeline order, selection, models, overlays, commitments, coupons, FX) | **rating-core** — unchanged in substance | +| Slice 08 replay/diff/reversal math; slice 09 split geometry, proration math, obligation shapes | **rating-core** (pure math); their *triggers* (correction ingestion, period tick) → pipeline | +| Slice 10 publish validators, rev-share pass-through, ASC refs | **rating-core** artifacts, registered in the pricing publish engine (unchanged) | +| Slice 11: five upstream contracts (Pricing, Subscriptions, Finance, Promotions, Billing-inbound) | **gear boundary contracts** of the rating gear (unchanged in substance) | +| Slice 11 §4.1 Rating handoff | becomes the **internal core↔pipeline crate API** (no longer a cross-gear contract) | +| Duties assigned to the "Rating" system actor (Q aggregation, dedup, unit synthesis, period tick, persistence, balance effects, cascade routing) | **pipeline slices 12–16** (graduate from actor description to first-class design) | +| Rating → Billing downstream handoff (previously the neighbour's undocumented duty) | **new gear boundary contract** (pipeline slice 16) | +| `PRD-rating-engine-202604031200` (VHP-810, draft/empty) | **absorbed**; upstream copy legacy (not maintained) | + +### Naming table + +| Old | New | Notes | +|---|---|---| +| gear `tariffs` (`gears/bss/tariffs/`) | gear `rating` (`gears/bss/rating/`) | `git mv`, history preserved | +| `cpt-cf-bss-tariffs-*` (315 unique ids / 488 occurrences) | `cpt-cf-bss-rating-*` | mechanical; includes this ADR's id | +| **PLAL** (implementation component) | **`rating-core`** (crate) | PLAL retired alongside the already-deprecated "Tariff Engine" | +| "Tariffs" as gear/domain name | "Rating" (gear); core vs pipeline as parts | | +| "tariff evaluation" (process) | "evaluation" / "price resolution" | | +| "tariff line", `TariffLineKey` | "charge line", `ChargeLineKey` | | +| "resolved tariff outcome", `ResolvedTariffOutcome` | "resolved price outcome", `ResolvedPriceOutcome` | | +| `…pricing-actor-tariffs` (pricing side, historical id) | `cpt-cf-bss-pricing-actor-rating` | **merged into the pre-existing rating actor** (commit C); plus prose mentions of "Tariffs" in pricing docs | +| word "tariff" | reserved for Pricing-gear rate definitions (rate card sense) only | | + +### Consequences + +* T-D-10 / T-D-11 / T-D-15 stop being cross-PRD obligations to an empty neighbour and become + intra-gear design (pipeline slices), each still traceable to its decision row. +* Review finding C6 dissolves: the outcome-mapping table (slice 11 §4.1) becomes the actual + pipeline design rather than a proposal awaiting a ratifier. +* The pure-core fence gets **stronger**: from a documentation boundary to a compiler-checked + crate boundary + CI deny-list + isolated golden fixtures. Normative rule carried over: the core + is invoked with frozen inputs only; no step evaluator reaches live state. +* Quote-time/estimate serving (open item) becomes cheap to extract later: `rating-core` is a + standalone crate by construction. +* The evaluation design set (slices 01–11) is **not** re-authored — only re-homed and re-titled; + its slice numbering and internal cross-references survive. +* One PRD grows: pipeline FR sections must be written (from T-D-10/11/15 + actor duties). This is + work that VHP-810 owed the program anyway. + +### Confirmation + +* `cfs validate` (0 errors) and `cfs validate-toc` stay green after each migration commit. +* No orphan references: `grep -r "cpt-cf-bss-tariffs-"` over the repo returns zero after Commit C (outside this ADR's historical naming table). +* The dependency fence exists from the first code commit: `rating-core`'s `Cargo.toml` carries no + I/O dependencies, and CI denies their introduction. +* Golden fixtures for the core run without the pipeline (fixture-only harness). + +## Migration Plan (impact map) + +Executed as ordered commits, each independently green: + +- **Step 0 — baseline**: commit the pending 2026-07-11 review-closure batch (slices 02–11 + authoring + T-D-09…T-D-15 edits) so the rename is a clean, mechanical diff. +- **Commit A — mechanical rename** (this repo, rating side): `git mv gears/bss/tariffs + gears/bss/rating`; rewrite ids (`cpt-cf-bss-tariffs-*` → `cpt-cf-bss-rating-*`), + `CONFLUENCE_TITLE`s, and the naming-table terms across the moved docs; `cfs validate` + + `validate-toc`. +- **Commit B — content**: PRD §1.1/§2.1 rewritten to "owns evaluation, not definitions" + (closes the stale *owns commercial price rules…* wording); §2.2 records VHP-810 absorption; + system actor "Rating & Charging" graduates from actor to pipeline slices; DESIGN.md index + regrouped (core 01–11 / pipeline 12–16); pipeline slice skeletons 12–16 created; SEAMS.md + retitled "Rating ⇄ Pricing"; DECISIONS.md wording (T-D-10 "cross-PRD: mirror in + Contracts/Rating" → "Contracts"; T-D-11/15 now intra-gear). +- **Commit C — pricing side** (same owner confirmed): `…pricing-actor-tariffs` → + `…-actor-rating`; "Tariffs" prose mentions across pricing PRD/DESIGN/DECISIONS/design/01–10/ADRs + (≈279 occurrences incl. fixtures like `inst-rv-tier-q` wording) → "Rating (evaluation core)". +- **Upstream not maintained**: `gears-rust` is the canonical home; the BSS manifest §4.2 PLAL-placement + caveat and `PRD-rating-engine-202604031200` (VHP-810) live only in upstream vhp-architecture, which + is legacy provenance — no back-port or supersession-marking obligation. +- **Artifacts board**: keep `/tariffs` URL live (published overview), add `/rating` alias at next + content update. +- **Rollback**: each commit is mechanical and independently revertible; `git mv` preserves + history for blame/log continuity. + +## Pros and Cons of the Options + +### Keep two gears, fix wording only + +* Good: zero migration cost. +* Bad: name keeps lying ("Tariffs" owns no tariffs); cross-PRD obligations against an empty PRD + persist; "which rating?" ambiguity persists; the empty VHP-810 still owes a PRD nobody owns. + +### Rename `tariffs` → `rating-core`, keep two gears + +* Good: fixes the misnomer; keeps gear-level fence around the core. +* Bad: same migration cost as consolidation but keeps the artificial seam: handoff stays a + cross-gear contract needing a ratifier, pipeline PRD still empty, two doc sets for one + deployable and one owner. Justified only by split team ownership — which does not exist here. + +### One `rating` gear (chosen) + +* Good: names encode responsibility; obligations become internal design; the pipeline PRD gap is + filled in the same doc set; the core fence strengthens (crate + CI instead of docs); one owner, + one deployable, one gear — structure matches reality. +* Bad: one larger design set (mitigated by the existing slice structure); ~315 ids and the + pricing-side prose must migrate (mechanical, one-time, at the cheapest possible moment); + discipline required so the core/pipeline split inside one gear stays sharp (mitigated by the + crate fence and fixture gate). + +## More Information + +Naming rationale: in classical BSS taxonomy the current "Tariffs" content *is* rating (applying +prices to usage), while the current "Rating" gear is mediation + orchestration; "tariff" denotes +the rate definitions owned by Pricing. See the ownership matrix in [`../SEAMS.md`](../SEAMS.md) +and the scope-migration record in [`../PRD.md`](../PRD.md) §2.2. + +## Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §1.1 (purpose), §2 (PLAL deployment note this ADR supersedes), + §2.1 (naming), §2.2 (scope migration, VHP-810). +- **Decisions**: T-D-16 (this consolidation), T-D-10/T-D-11/T-D-15 (obligations becoming + intra-gear) — [`../DECISIONS.md`](../DECISIONS.md). +- **Seams**: [`../SEAMS.md`](../SEAMS.md) (retitled Rating ⇄ Pricing in Commit B). +- **Design**: [`../design/01-foundation.md`](../design/01-foundation.md) §3.8 (deployment), + [`../design/11-consumer-contracts.md`](../design/11-consumer-contracts.md) §4.1 (handoff → internal API). diff --git a/gears/bss/rating/docs/DECISIONS.md b/gears/bss/rating/docs/DECISIONS.md new file mode 100644 index 000000000..eff63f6aa --- /dev/null +++ b/gears/bss/rating/docs/DECISIONS.md @@ -0,0 +1,58 @@ + + + +# Rating — Decision Register + + + +- [Decision register](#decision-register) +- [Open items](#open-items) +- [Source](#source) + + + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-decisions` + +> Rating design decisions (rows T-D-01…T-D-15 were authored under the gear's historical name +> "Tariffs" — see T-D-16 / ADR-0002). The load-bearing early decisions are **inherited from the +> cross-gear seam analysis** with the pricing gear (2026-07-10) and recorded verbatim in +> [`SEAMS.md`](./SEAMS.md); this register is the gear-local index over them plus decisions taken +> during Design. Seam ids (K/O/S/W/M/P/F/B/C/G/N) resolve into [`SEAMS.md`](./SEAMS.md). + +## Decision register + +| # | Decision | Source seam | Status | +|---|----------|-------------|--------| +| T-D-01 | Adopt the pricing 8-axis canonical scope key for selection + non-overlap; `phase` is `phase_id`; multi-generation grandfathering selects by the pinned price id's `cohort`. | K1-K5 | **adopted 2026-07-10** | +| T-D-02 | Step-4 overlays **stack** all survivors; class-specificity order breaks ties, not exclusivity. | O1-O3 | **adopted 2026-07-10** (pricing to confirm tie-break-not-exclusivity) | +| T-D-03 | One `pricingSnapshotRef`, three writers; Tariffs is the composition SoR. | S1 | **adopted 2026-07-10** | +| T-D-04 | Open-period re-resolution replays strictly from the pinned snapshot (no live catalog read); tier-counter key `(subscription, meter, dimensionKey, window)`. | W2, M7 | **adopted 2026-07-10** | +| T-D-05 | `modelKind = {flat, per_unit, graduated, volume, package}`; per_unit, package, and composite/derived meter are in launch; Volume Variant B not authorable; hybrid/committed are compositions. | M1-M5 | **adopted 2026-07-10** | +| T-D-06 | Single pricing Slice 5 approval engine; Tariffs registers fail-closed validators, not a second workflow; ledger `dual_control` stays separate (different bounded context). | G1 | **adopted 2026-07-10** | +| T-D-07 | `prorationBasis` (incl. `none`) and `billingAnchorPolicy` + D-20 clamp adopted verbatim; enum-drift CI gate. | P1, P2 | **adopted 2026-07-10** | +| T-D-08 | Usage rows phase-invariant by default (phase-specific wins); reserved-rate two-source split; effective rev-share pass-through for bundle summing. | F1, M9, B1 | **adopted 2026-07-10** | +| T-D-09 | `pricingSnapshotRef` gains the **eighth named segment `commitmentReservation`** (reservation match: id, flavor, `reservedQuantity`, rate source; pool set: per-pool id, unit, `poolType`, balance @ `balanceVersion`, draw order, rollover; reserved-vs-pool split); writer Tariffs @ eval — closes the S1 segment-naming residue. | S1 | **adopted 2026-07-11** | +| T-D-10 | Commitment balance lifecycle: Rating publishes per-outcome `CommitmentBalanceEffect`s (idempotent on the outcome's evaluation/correction key); Contracts serializes per-pool `balanceVersion`; a balance-affecting correction cascades delta-only re-resolution of later-`balanceVersion` units that drew (or were rated overage against) the pool. | M8 → design/05 §4.1 | **adopted 2026-07-11** (cross-PRD: mirror in Contracts; the Rating side is intra-gear since T-D-16 — pipeline slice 15) | +| T-D-11 | Delta-dedup owner = **Rating** — persists delta outcomes by correction key; retried keys return the recorded outcome; Billing consumes Adjustments idempotently (defense in depth). | design/01 §2.2, design/08 §2.2 | **adopted 2026-07-11** | +| T-D-12 | Windowed models across intra-window boundaries: per-slice sub-window evaluation units with the frozen **`bandOffsetQ`**; window-activation/phase-conversion boundaries always carry (pricing `inst-tb-window-continuity`), plan-change per carry-vs-reset flag; volume selects the band by window-cumulative `Q`; package counts blocks by cumulative ceil-diff; the correction key gains the slice coordinate `(window[, slice], prior-rated-version, snapshot)`. | M7, P2 → design/03 §4.3 | **adopted 2026-07-11** | +| T-D-13 | Step-6 recompute contract: a consumption-reservation split re-runs **steps 3–5 as a unit** over the on-demand remainder (re-band from zero; the frozen overlay stack + contract overlay re-apply); the reserved portion is not re-overlaid. Tier-counter exclusion + pool asymmetry (pricing `inst-rv-tier-q`) registered in PRD §6.6 / AC 19. | M9 → design/05 §4.2, design/04 §4.2 | **adopted 2026-07-11** | +| T-D-14 | Commitment pool flavors: frozen `poolType ∈ {prepaid_drawdown, committed_rate}` — billability per flavor (due-zero + notional lineage vs in-arrears at the committed rate) and normative shortfall true-up formulas (quantity/spend basis); the commitment sale itself bills outside PLAL (Contracts/Billing at sale). | M8 → design/05 §4.1/§4.5 | **adopted 2026-07-11** | +| T-D-15 | Period-driven evaluation units (recurring lines, capacity-flavor charges, true-up surfacing) keyed `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)`, synthesized by **Rating's period tick** at anchor boundaries — no Tariffs scheduler; a zero-usage period still emits its period-driven units. | design/01 §4.2, design/11 §4.1 | **adopted 2026-07-11** | +| T-D-16 | **Gear consolidation**: Tariffs (evaluation core) and the Rating pipeline become **one `rating` gear** — the core as a no-I/O `rating-core` crate (evaluation slices 01–11 survive as-is), pipeline slices 12–16 absorb the empty rating-engine scope (VHP-810); "tariff" returns to the Pricing vocabulary (rate definitions); T-D-10/11/15 become intra-gear. Full naming + migration map: [`ADR/0002`](./ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). | ADR-0002 | **accepted 2026-07-11** (single owner); commits A–B landed, residues tracked below | +| T-D-17 | **Level-based (gauge) aggregation via granule fold** (joint with pricing D-44, supersedes the F-40 "sum-only launch"): for `aggregationFunction ∈ {peak, time_weighted}` the meter consumes point-stamped gauge samples; the window cuts into frozen `aggregationGranularity ∈ {hour, day}` granules, each folds deterministically (max / step-integral with bounded `hold_last`, gap → 0 + operator signal, never a guess), and window `Q` = **Σ granule folds — additive by construction**, so M7 single-writer key, supersession continuity, T-D-12 `bandOffsetQ`, band/package math, and delta-only corrections are untouched; a late sample re-folds only its granule (qVersion++). Billable unit = level·granule-hours (SKU-declared), distinct from the sample's level unit. No composite co-occurrence at launch. Product drivers: cloudlet peak-per-hour, storage GB-month. | pricing D-44; PRD `fr-level-aggregation` §6.5/§17.1; design/03 §4.3, design/12 §2.2, design/13 §4.1; SEAMS M10/UC6 | **adopted 2026-07-16 (product call, confirmed)** | + +## Open items + +- **O3** — pricing-side confirmation that "most-specific-wins" is a tie-break, not overlay exclusivity. +- **M6** — dimensional launch posture wording (declaration in-scope now; value-pricing OSS-gated). +- **Pre-existing (vendored PRD):** §5.1 acceptance-criteria cross-references drifted from §12 numbering (max AC 20) — reconcile during Design. +- **From the 2026-07-11 slice review** (details: [`SEAMS.md`](./SEAMS.md) technical residues): composite × dimensions input-join rule (design/03 §3.6); mixed-settlement coupon rules — fail-closed meanwhile (design/06 §4.2); seat-change boundary transport with Subscriptions (design/09 §4.3); bundle-level coupon attachment (design/10 §4.5). Foundation opens: DECIMAL precision (with Billing), clamp-vs-credit (PRD §15). *Resolved 2026-07-11:* S1 segment naming → T-D-09; delta-dedup owner → T-D-11; balance write-back → T-D-10. +- **From the 2026-07-11 tri-review** (to close before Design lock): coupon equal-benefit tie-break (design/06 §4.2 — default proposal: ascending `couponId`); `whole_unit` attribution on a split (design/09 §4.1); validator-4 enforcement point — Contracts-side publish gate (design/10 §4.4); validator packaging — shared crate vs pricing-side impl (design/10); RatedCharge/BillableItem mapping ratification by the pipeline design (design/11 §4.1 → pipeline slice 15 — intra-gear since T-D-16). +- **Registry seams (`products` gear — PRD vendored 2026-07-16 to `gears/bss/products/docs/PRD.md`, from PR #4177):** RG1 freeze-protocol composition (registry `freezeComplete` × pricing pin/warm-completion × rating W2 replay — rating is not in the freeze-participant set) — **still open**; RG2 single-unit-per-SKU vs dimensional lines — **closed** (fix applied in the vendored copy); RG3 pre-consolidation naming — localization debt recorded in the vendored PRD's provenance note. Tracked in [`SEAMS.md`](./SEAMS.md) §I. +- **Migration residues (ADR-0002 / T-D-16):** prose re-voicing of the evaluation-core slices (historical "Tariffs" → the core, neighbour-"Rating" → the pipeline) — bridge notes in [`DESIGN.md`](./DESIGN.md) / [`SEAMS.md`](./SEAMS.md) apply meanwhile; the `Tariffs`/`PLAL` → `Rating`/`rating-core` prose rename is **done in the living docs** (design slices 01–16, DESIGN.md, README, PRD.md — *Done 2026-07-16*); this register, [`SEAMS.md`](./SEAMS.md), and the ADRs deliberately retain the historical "Tariffs"/"PLAL" naming as dated decision/analysis records (each carries a bridge note). The **pricing-side Commit C** ("Tariffs" prose in `gears/bss/pricing/docs`) remains the one open cross-gear rename. (Upstream vhp-architecture is legacy provenance, not maintained — no propagation obligation for the manifest §4.2 note or the `PRD-rating-engine-202604031200` marker.) *Done 2026-07-11:* artifacts-board `/rating` alias (page retitled; `/tariffs` kept as an alias). *Done 2026-07-15:* pipeline slices 12–16 authored beyond skeleton (full §1–§5); the slice-11 §4.1 handoff mapping ratified intra-gear by slice 15 (finding C6 closed). + +## Source + +Full seam analysis, rationale, ownership matrix, and rejected alternatives are in +[`SEAMS.md`](./SEAMS.md) (4-agent cross-gear pass, 2026-07-10). Requirements in +[`PRD.md`](./PRD.md). diff --git a/gears/bss/rating/docs/DESIGN.md b/gears/bss/rating/docs/DESIGN.md new file mode 100644 index 000000000..75d86f1b6 --- /dev/null +++ b/gears/bss/rating/docs/DESIGN.md @@ -0,0 +1,240 @@ + + + +# Technical Design — Rating (Evaluation Core + Pipeline) + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional context](#4-additional-context) +- [5. Traceability](#5-traceability) + + + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-main` + +> **Canonical design entry point and index.** This document is the rating gear's top-level +> technical design and the anchor for spec traceability. The design is a **set of slice documents** +> under [`design/`](./design/): the **evaluation core** (slices 01–11 — a shared Evaluation +> Foundation plus per-step evaluator slices along the §17.1 rule order) and the **operational +> pipeline** (slices 12–16, per ADR-0002). This page is the single index over that set and +> delegates slice-level detail to the slice documents. +> +> **Terminology bridge (ADR-0002)**: inside the evaluation-core slices, historical "**Rating**" +> reads as *the evaluation core* (`rating-core`), and historical neighbour-"**Rating**" reads as +> *the rating pipeline* — one gear since consolidation; full prose re-voicing is a tracked +> migration residue ([`DECISIONS.md`](./DECISIONS.md)). +> +> **Status**: evaluation core (01–11) and operational pipeline (12–16) authored, pre-Design-lock. + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **rating gear** is the BSS usage-rating domain (ADR-0002): **rating-core** — the +commercial-price evaluation engine — plus the operational **rating pipeline**. Given a frozen +evaluation context at `t`, the core resolves effective rates, the pricing model, overlay winners, +commitment/reservation effects, coupons, and FX to a **resolved price outcome** plus a +`pricingSnapshotRef` — **byte-for-byte reproducibly**; the pipeline supplies the frozen inputs and +persists the outcome (slices 12–16). The gear is **not** an authoring System of Record: the +pricing gear owns the catalog (the canonical scope key, `PriceWindow`, `PriceOverlay`, +`CatalogVersion`, and publish governance); the core **adopts** those and evaluates over a frozen +snapshot ([`PRD.md`](./PRD.md) §1.1). + +The complementarity contract with the pricing gear — the 8-axis key, cohort selection, overlay +stacking, snapshot composition, single governance engine, launch-scope models — is frozen in +[`SEAMS.md`](./SEAMS.md); every slice here implements the rating side of a resolved seam. + +### 1.2 Architecture Drivers + +- **Determinism**: a pure-function core over frozen inputs `(window-aggregated Q, pricingSnapshotRef, fxTableVersion)` — identical across replay, recompute, and cross-region batch workers. +- **Adopt, don't fork**: the canonical scope key, `PriceWindow` machinery, and publish governance are the pricing gear's SoR; Rating consumes them verbatim (no divergent key, no second approval engine) — see ADR `cpt-cf-bss-rating-adr-scope-key-adoption`. +- **One rating gear (accepted 2026-07-11)**: this design set is the **evaluation core** of the consolidated `rating` gear — the core becomes a no-I/O `rating-core` crate, the operational pipeline (mediation, `Q`, dedup, period tick) joins as pipeline slices, and "tariff" returns to the Pricing vocabulary — see ADR `cpt-cf-bss-rating-adr-rating-gear-consolidation`; migration pending (T-D-16). +- **Snapshot-only hot path**: no mutable catalog re-query at evaluation/correction time; open-period re-resolution replays the pinned snapshot. +- **Scale**: horizontal per-partition evaluation, no cross-partition locks on the hot path. + +### 1.3 Architecture Layers + +A shared **Evaluation Foundation** (`01-foundation`) carries the pure-function core, the adopted +scope key, determinism, and `pricingSnapshotRef` composition. Each subsequent slice is an +**evaluator** contributing one region of the §17.1 rule order, running through the Foundation over +a frozen snapshot. The numeric prefix follows the §17.1 evaluation order, then the period-level and +cross-cutting slices. + +| Slice | Title | PRD §6 | Seams owned | +|-------|-------|--------|-------------| +| [`design/01-foundation.md`](./design/01-foundation.md) | Evaluation Foundation | §6.1, §17.1 core | K1, K3, S1, M7, W2, M11 | +| [`design/02-selection-eligibility.md`](./design/02-selection-eligibility.md) | Base Selection & Eligibility | §6.3, §6.5 | K1, K2, K4, K5, F1 | +| [`design/03-metering-models.md`](./design/03-metering-models.md) | Metering & Pricing Models | §6.2, §6.5, §6.7 | M1, M2, M3, M5, M6, M7, M10, M11 | +| [`design/04-overlays-precedence.md`](./design/04-overlays-precedence.md) | Overlays & Precedence | §6.4 | O1, O2, O3 | +| [`design/05-commitments-reservations.md`](./design/05-commitments-reservations.md) | Commitments & Reservations | §6.6 | M8, M9 | +| [`design/06-coupons.md`](./design/06-coupons.md) | Coupons | §6.8 | — | +| [`design/07-currency-fx.md`](./design/07-currency-fx.md) | Multi-Currency & FX | §6.9 | S1 | +| [`design/08-retroactivity-corrections.md`](./design/08-retroactivity-corrections.md) | Retroactivity & Corrections | §6.10 | W2 | +| [`design/09-period-plan-change.md`](./design/09-period-plan-change.md) | Period & Plan-Change Obligations | §6.11 | P1, P2 | +| [`design/10-governance-asc606.md`](./design/10-governance-asc606.md) | Governance & ASC 606 | §6.12 | G1, B1 | +| [`design/11-consumer-contracts.md`](./design/11-consumer-contracts.md) | Consumer & Integration Contracts | §9 | C1, S1, B1 | +| [`design/12-usage-ingestion-normalization.md`](./design/12-usage-ingestion-normalization.md) | Usage Ingestion & Normalization *(pipeline)* | §9.2 | — | +| [`design/13-q-store-attribution.md`](./design/13-q-store-attribution.md) | Windowed Q Store & Attribution *(pipeline)* | §9.2 | M7 (writer side) | +| [`design/14-unit-synthesis-period-tick.md`](./design/14-unit-synthesis-period-tick.md) | Unit Synthesis & Period Tick *(pipeline)* | §9.2 | — (T-D-15) | +| [`design/15-rated-output-balance-effects.md`](./design/15-rated-output-balance-effects.md) | Rated Output, Delta Dedup & Balance Effects *(pipeline)* | §9.2 | — (T-D-10/11) | +| [`design/16-billing-handoff-operations.md`](./design/16-billing-handoff-operations.md) | Billing Handoff & Operations *(pipeline)* | §9.2 | — | + +## 2. Principles & Constraints + +### 2.1 Design Principles + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-pure-function-core` + No I/O and no mutable-state read on the evaluation path; `Q` aggregation is Rating's (single-writer per `(subscription, meter, dimensionKey, window)`), consumed frozen. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-fixed-rule-order` + §17.1 steps 1-9 are invariant; there is no reordering knob. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-fail-closed` + No silent fallback on an unmatched window, ambiguous mapping, missing policy, or missing `periodState`. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-adopt-the-sor` + The canonical scope key, `PriceWindow`, `PriceOverlay`, and publish governance are consumed verbatim from the pricing gear. + +### 2.2 Constraints + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-posted-immutability` + Corrections flow as deltas; they never mutate posted invoices or prior outputs. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-domain-boundaries` + No tax, no revenue recognition, no coupon lifecycle, no spend enforcement (PRD §5.2). +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-stateless-hot-path` + The evaluation core (`rating-core`) owns no authoritative persistent store and holds no mutable state on the hot path; all commercial inputs arrive frozen (§3.7). The **pipeline** side of the gear does own authoritative stores (usage dedup — slice 12; windowed `Q` — slice 13; rated-output + delta-dedup index — slice 15); this constraint scopes the core, not the gear. + +## 3. Technical Architecture + +### 3.1 Domain Model + +_TBD (skeleton)._ The evaluation-context aggregate, the resolved-price-outcome value object, and +the `pricingSnapshotRef` composite are owned by `01-foundation`; per-step domain shapes by their +slices. Rating holds no catalog entities — those are frozen inputs from the pricing gear. + +### 3.2 Component Model + +The evaluation core is the eleven slices in §1.3, each a pure-function contributor to one region +of the §17.1 order, composed by the Foundation's evaluation pipeline; the five pipeline slices +(12–16, ADR-0002) carry the operational components. Each carries a stable +`cpt-cf-bss-rating-component-{slug}` ID; internals live in the slice documents. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-foundation` + Evaluation Foundation: pure-function core, adopted scope key, determinism, `pricingSnapshotRef` composition. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-selection-eligibility` + Steps 1-2 selection, eligibility class order, cohort generation selection. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-metering-models` + Step 3 mapping + model formulas + tier window + dimensional/composite. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-overlays-precedence` + Steps 4-5 PriceOverlay stacking + contract overlay + anti-drift cap. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-commitments-reservations` + Step 6 drawdown + reservation flavors. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-coupons` + Step 7 coupon application + stacking. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-currency-fx` + Step 8 FX policy. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-retroactivity-corrections` + Posted-period protection + late-arrival replay + reversals. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-period-plan-change` + Floor/cap + proration obligations. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-governance-asc606` + Validator registration + ASC 606 + rev-share pass-through. +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-consumer-contracts` + Rating / pricing / Subscriptions / Finance / Promotions / Billing contracts. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-usage-ingestion` + Pipeline: usage intake, normalization, session merge, authoritative usage dedup store. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-q-store` + Pipeline: windowed `Q` single-writer store, per-slice attribution + `bandOffsetQ`, re-materialization. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-unit-synthesis` + Pipeline: evaluation-unit synthesis, period tick, frozen-context assembly, bounded cascade routing. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-rated-output` + Pipeline: rated-output persistence, RatedCharge/BillableItem mapping, delta dedup, `CommitmentBalanceEffect` publication. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-billing-handoff` + Pipeline: Billing handoff (outbox), `periodState` relay, lanes/backpressure/replay, NFR verification. + +### 3.3 API Contracts + +_TBD (skeleton)._ The conceptual evaluation contract (context → resolved outcome + `pricingSnapshotRef`) +and the integration contracts (Rating handoff; Pricing read-model input; Subscriptions, Finance-FX, +Promotions, Billing) are owned by `11-consumer-contracts` and PRD §9. + +### 3.4 Internal Dependencies + +Every core slice depends on `01-foundation`. Evaluation order (data flow): `02 → 03 → 04 → 05 → 06 → 07` +per §17.1 steps 1-8; `08` (retroactivity) and `09` (period/plan-change) wrap the per-line flow; +`10` (governance) gates publish; `11` (contracts) is the boundary surface. Pipeline slices +(ADR-0002) wrap the core operationally: `12` feeds normalized usage, `13` materializes `Q`, +`14` synthesizes evaluation units and assembles the frozen context, `15` persists outcomes and +publishes balance effects, `16` hands off to Billing. + +### 3.5 External Dependencies + +- **Pricing (Product Catalog) gear** — SoR for the scope key, `PriceWindow`, `PriceOverlay`, `CatalogVersion`; produces `PriceWindow*` / `CatalogVersionPublished` events and the frozen read model. +- **Subscriptions** — phase, eligibility inputs, seat count, plan-change policy. +- **Finance** — FX tables and lock policies. **Promotions** — frozen coupon snapshots. **Billing** — `periodState`, floor/cap and rounding execution. + +### 3.6 Interactions & Sequences + +The normative step-1-9 evaluation sequence lives in PRD §17.1; per-slice sequences are TBD in the +slice documents. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-seq-evaluate-tariff` + Deterministic step-1-9 evaluation of one line from a frozen context to a resolved outcome + `pricingSnapshotRef`. +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-seq-open-period-reresolve` + Late-arriving usage re-resolution replayed strictly from the pinned snapshot, emitting delta adjustments. + +### 3.7 Database schemas & tables + +**Core: none owned; pipeline: three stores.** The evaluation core (`rating-core`, slices 01–11) is +a pure-function evaluator over frozen inputs and holds **no authoritative persistent store**; any +evaluation-side cache (e.g. a resolved-window cache invalidated by `PriceWindow*` events) is +non-authoritative and defined per slice. Post-consolidation (ADR-0002 / T-D-16) the **pipeline** half +of the gear owns the authoritative stores: the **usage dedup store** (slice 12), the **windowed `Q` +store** (slice 13, single-writer per `(subscription, meter, dimensionKey, window)`), and the +**rated-output store + delta-dedup index** (slice 15). External commercial state stays with its SoR: +the pricing gear (catalog), Contracts (`commitmentPools[]` balances), and Finance (FX). + +### 3.8 Deployment Topology + +One deployable: **`rating-core`** is a pure, I/O-free crate inside the `rating` gear (ADR-0002 — +supersedes the earlier placement note that treated the evaluation core as a logical module of Rating; the one-deployable, +no-separate-evaluation-service constraint is preserved and strengthened to a crate boundary). +Horizontal per-partition evaluation with no cross-partition locks on the hot path (PRD §7.1, §14). + +## 4. Additional context + +**Cross-cutting normatives** (frozen resolutions from [`SEAMS.md`](./SEAMS.md), binding every slice): + +- **Canonical scope key (K1-K5):** selection and non-overlap use the pricing 8-axis key `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)`; `phase` is a `phase_id`; grandfathering selects the generation by the pinned price id's `cohort`. +- **Overlays (O1-O3):** step 4 **stacks** all PriceOverlay survivors; the class-specificity order `customerGroup > partner > orgTier > brand > region > global` breaks ties, not exclusivity. +- **Snapshot (S1):** one `pricingSnapshotRef`, three writers — pricing pre-stamps the catalog subset, Rating (composition SoR) adds overlay/coupon/FX-lock, Subscriptions freezes the `(currency, region)` binding. +- **Determinism / corrections (W2, M7):** replay strictly from the pinned snapshot; counter key `(subscription, meter, dimensionKey, window)`. +- **Governance (G1):** a single pricing Slice 5 approval engine; Rating registers fail-closed validators, never a second workflow; ledger `dual_control` stays a separate bounded context. +- **Models (M1-M5):** `{flat, per_unit, graduated, volume, package}`; per_unit, package, and composite are in launch; Volume Variant B not authorable; hybrid/committed are compositions. +- **Enums (P1-P2):** `prorationBasis` (incl. `none`) and `billingAnchorPolicy` adopted verbatim (CI gate `pricing.contracts.enum_drift`). + +**ADR index:** + +- [`ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](./ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md) (`cpt-cf-bss-rating-adr-scope-key-adoption`) — adopt the pricing 8-axis canonical scope key + cohort selection rather than define a Rating key. +- [`ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](./ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md) (`cpt-cf-bss-rating-adr-rating-gear-consolidation`) — consolidate Rating (evaluation core) and the Rating pipeline into one `rating` gear; core = no-I/O crate; naming + migration map (T-D-16). +- _Further ADRs (determinism/replay model; snapshot composition split; single governance engine) to be seeded during Design._ + +## 5. Traceability + +- **Requirements:** [`PRD.md`](./PRD.md) (§6 functional, §17.1 normative rule order). +- **Cross-gear contract:** [`SEAMS.md`](./SEAMS.md) (seam register + decision log). +- **Decisions:** [`DECISIONS.md`](./DECISIONS.md) (Rating decision register). +- **Slices:** [`design/`](./design/) (per-slice designs) and [`design/README.md`](./design/README.md). diff --git a/gears/bss/rating/docs/PRD.md b/gears/bss/rating/docs/PRD.md new file mode 100644 index 000000000..f2afb11b2 --- /dev/null +++ b/gears/bss/rating/docs/PRD.md @@ -0,0 +1,1387 @@ +--- +refs: + - bss/manifest/vz-arch-manifest-bss-only.md + - bss/prd/PRD-billing-ledger-balances-202604041200/PRD-billing-ledger-balances-202604041200.md + - bss/prd/PRD-contracts-agreements-202601120119/PRD-contracts-agreements-202601120119.md + - bss/prd/PRD-metering-pricing-module-202601120119/PRD-metering-pricing-module-202601120119.md + - bss/prd/PRD-product-catalog-marketplace-202601120119/PRD-product-catalog-marketplace-202601120119.md + - bss/prd/PRD-rating-engine-202604031200/PRD-rating-engine-202604031200.md + - bss/prd/PRD-subscriptions-lifecycle-202604021200/PRD-subscriptions-lifecycle-202604021200.md +--- + +# PRD — Rating — Usage Rating & Commercial Pricing Logic + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Architecture Alignment](#2-architecture-alignment) + - [2.1 Terminology and Naming](#21-terminology-and-naming) + - [2.2 Predecessor PRDs and Scope Migration](#22-predecessor-prds-and-scope-migration) +- [3. Actors](#3-actors) + - [3.1 Human Actors](#31-human-actors) + - [3.2 System Actors](#32-system-actors) +- [4. Operational Concept & Environment](#4-operational-concept--environment) + - [4.1 Module-Specific Environment Constraints](#41-module-specific-environment-constraints) +- [5. Scope](#5-scope) + - [5.1 In Scope](#51-in-scope) + - [5.2 Out of Scope](#52-out-of-scope) +- [6. Functional Requirements](#6-functional-requirements) + - [6.1 Deterministic Evaluation](#61-deterministic-evaluation) + - [6.2 Pricing Models](#62-pricing-models) + - [6.3 Rule Evaluation Order](#63-rule-evaluation-order) + - [6.4 Override Hierarchy and Overlays](#64-override-hierarchy-and-overlays) + - [6.5 Tier Aggregation, Eligibility, Phases, Granularity](#65-tier-aggregation-eligibility-phases-granularity) + - [6.6 Commitments and Reservations](#66-commitments-and-reservations) + - [6.7 Dimensional (Cloud) Pricing](#67-dimensional-cloud-pricing) + - [6.8 Coupons (Promotions Overlay)](#68-coupons-promotions-overlay) + - [6.9 Multi-Currency and FX](#69-multi-currency-and-fx) + - [6.10 Retroactivity and Corrections](#610-retroactivity-and-corrections) + - [6.11 Period-Level and Plan-Change Obligations](#611-period-level-and-plan-change-obligations) + - [6.12 Governance and ASC 606 Traceability](#612-governance-and-asc-606-traceability) +- [7. Non-Functional Requirements](#7-non-functional-requirements) + - [7.1 NFR Inclusions](#71-nfr-inclusions) + - [7.2 NFR Exclusions](#72-nfr-exclusions) +- [8. Five Quality Vectors Analysis](#8-five-quality-vectors-analysis) +- [9. Public Library Interfaces](#9-public-library-interfaces) + - [9.1 Public API Surface](#91-public-api-surface) + - [9.2 External Integration Contracts](#92-external-integration-contracts) +- [10. Use Cases](#10-use-cases) +- [11. User Interaction and Design](#11-user-interaction-and-design) +- [12. Acceptance Criteria](#12-acceptance-criteria) + - [Price resolution and determinism](#price-resolution-and-determinism) + - [Pricing models](#pricing-models) + - [Time, versioning, currency](#time-versioning-currency) + - [Retroactivity and corrections](#retroactivity-and-corrections) + - [ASC 606 traceability](#asc-606-traceability) + - [Tier aggregation, overlays, and eligibility](#tier-aggregation-overlays-and-eligibility) + - [Promotions and coupons](#promotions-and-coupons) + - [Plan change and proration](#plan-change-and-proration) + - [Cloud resource pricing](#cloud-resource-pricing) + - [Non-Functional Requirements (Show-Stoppers)](#non-functional-requirements-show-stoppers) +- [13. Dependencies](#13-dependencies) +- [14. Assumptions](#14-assumptions) +- [15. Open Questions](#15-open-questions) +- [16. Risks](#16-risks) +- [17. Reference Materials](#17-reference-materials) + - [17.1 Rule Evaluation Order (normative appendix, steps 1-9)](#171-rule-evaluation-order-normative-appendix-steps-1-9) + - [17.2 Boundary Contracts (coupons, floor/cap, plan-change proration)](#172-boundary-contracts-coupons-floorcap-plan-change-proration) + - [17.3 Cloud Catalog Readiness and Phasing](#173-cloud-catalog-readiness-and-phasing) + - [17.4 Future Scope](#174-future-scope) + + + +## 1. Overview + +### 1.1 Purpose + +**Rating** is the BSS gear that turns metered usage and subscription state into **deterministic, auditable charges**. Per [ADR-0002](./ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md) it consists of two parts in one deployable: the **evaluation core** (`rating-core`) — a pure function that resolves **effective commercial prices and charge formulas** for subscriptions and usage meters in a **multi-tenant hierarchy** (platform owner → channel partner / reseller → end customer), under **usage-based and hybrid commercial models**, with **financial-grade auditability** and **byte-for-byte reproducible** outputs — and the **operational pipeline** (usage ingestion, windowed `Q` aggregation, dedup, evaluation-unit synthesis, rated-output persistence, handoffs — design slices 12–16). **Evaluation** (§6.3 / §17.1) emits a **resolved price outcome** plus a `pricingSnapshotRef`. + +This gear owns the **evaluation semantics and the rating pipeline — not the price definitions**: tariffs (rate cards, pricing models, price windows, price overlays, effective dating, publish controls) are authored and owned by the **Pricing (Product Catalog)** gear and consumed here frozen (*adopt, don't fork* — [SEAMS](./SEAMS.md)). It does **not** compute tax, recognize revenue, manage coupon lifecycle, or enforce spend — those remain in their owning domains (§5.2). + +### 1.2 Background / Problem Statement + +BSS must monetize a real IaaS catalog (S3, VM, Disks) across a partner/reseller hierarchy with usage-based, hybrid, and committed-usage models. Without explicit, normative pricing semantics, rating batches, replay, and late-arrival handling diverge, and partner/customer override precedence becomes ad hoc — producing non-reproducible charges, disputes, and non-auditable financials. + +This PRD fixes the **formula semantics** (flat, per_unit, tiered, volume, package, hybrid, committed-usage), the **deterministic evaluation order**, the **multi-currency** separation (price vs billing currency vs FX policy), and **CFO-grade controls** (rule-version audit, UTC effective dating, segregation of duties on publish, ASC 606-compatible tagging) so Design implements — not invents — these rules. + +Industry alignment: usage-based pricing platforms (Metronome, Lago, OpenMeter) are the coverage/sequencing benchmark for cloud model breadth (dimensional, composite, capacity/reservation); partner economics require explicit precedence integers on price overlays; retroactivity never mutates posted invoices and uses delta adjustments with lineage (manifest §4.2). + +### 1.3 Goals (Business Outcomes) + +- **Determinism**: given frozen inputs `(window-aggregated inputs, pricingSnapshotRef, fxTableVersion)`, the monetary outcome is identical across replay, recompute, and cross-region batch workers; all divergences without input change are defects. +- **Model coverage**: flat, per_unit (per-seat), tiered (graduated), volume (Variant A), package (block), hybrid (recurring + usage), and committed-usage (drawdown + overage + true-up) are supported with explicit, configured semantics. +- **Multi-currency correctness**: price currency, invoice/settlement currency, and FX policy (rate-lock per window or invoice-period FX) are separated; no implicit provider-default FX. +- **Auditability**: rule-version audit trail, UTC effective dating, two-person rule on material publish, and ASC 606-compatible allocation inputs (PO tags, SSP pointers) carried as references — not a recognition engine. +- **Scale**: horizontal evaluation per tenant/partition with bounded p95 latency and no cross-partition locks on the hot path (working-assumption targets in §7.1 until NFR workshop). + +### 1.4 Glossary + +| **Term** | **Definition** | +|----------|----------------| +| **Tariff** | A versioned commercial rule set binding metering dimensions to a **pricing model** and **evaluation policy** for a Plan/Price row or overlay. Persisted via Catalog + contract overlays + snapshot refs (see Design for entity mapping). The word names **Pricing-owned rate definitions**; since ADR-0002 it no longer names this gear. | +| **Resolved price outcome** | The output of one **evaluation**: effective rates, pricing model kind, tier thresholds, overlay winners, and snapshot identifiers — not a separate Catalog entity. | +| **Evaluation context** | Inputs to resolve one price outcome: tenant axes (`resourceTenantId`, `payerTenantId`, `sellerTenantId`), subscription/plan linkage, **subscription phase**, **`planTier`**, SKU/meter, quantity or time slice (after **billing granularity** normalization), **`tierAggregationWindow`** policy, timestamp `t` (UTC), currency/region/brand scope, **`periodState`** (`open` \| `closed_posted`, from Billing), optional **`reservationMatch`**, optional **`changeEffectiveAt` / `changeMode`**, and applicable **snapshot identifiers**. | +| **periodState** | Open/closed state of the billing period covering `t`, **supplied by Billing**. `open` → retroactive/late events may re-resolve the window and FX may be provisional; `closed_posted` → posted-period immutability applies and corrections MUST be delta-only. Required input for the retroactivity branches. | +| **reservationMatch** | Optional input describing reserved/provisioned capacity at `t`: reserved rate, reserved/allocated quantity (`reservedQuantity`), and an optional usage-coverage flag. Two charge flavors: **(a) consumption-flavor** (matched usage at reserved rate, remainder on-demand); **(b) capacity-flavor** (allocated quantity charged at reserved rate regardless of usage). Entitlement lifecycle/inventory is cross-PRD (OSS/Contracts). | +| **capacityCharge** | The capacity-flavor charge: a recurring-style charge on `reservedQuantity` (e.g. provisioned-disk GB, provisioned IOPS) at the reserved rate, emitted per period independent of usage; evaluated at step 6. | +| **Tier aggregation window** | Policy governing when tier counter `Q` resets for tiered/volume models: `calendar_month`, `invoice_period`, `subscription_lifetime`, or `per_event`. MUST be configured on the Price/plan policy and frozen in `pricingSnapshotRef`. `calendar_month` delimited in UTC; `invoice_period` anchored to the subscription billing anchor per catalog `billingAnchorPolicy` (UTC; D-20 no-drift clamp). Thresholds are half-open `[lower, upper)` — a quantity at a boundary falls in the UPPER band. Intra-window boundaries (mid-cycle activation, plan change, phase conversion) do NOT reset the counter by themselves: each sub-window slice prices its own attributed quantity with a **band offset** equal to the accumulated prior-slice `Q` (tier-counter continuity per pricing `inst-tb-window-continuity`); only a plan-change boundary may reset via the frozen carry-vs-reset flag (§6.11, §17.2). | +| **Billing granularity** | Minimum billable unit for a usage price (`per_second`, `per_minute`, `per_hour`, `per_day`, or whole-unit). Usage duration/quantity rounded **up** to this unit before rate application. A per-resource `minimumCharge` MAY bound ephemeral-resource over-charge (§15). | +| **dimensionKey** | Ordered tuple of pricing-relevant event dimensions that, with `meter`, identifies one charge line. Empty tuple = a meter with no declared dimensions. Invariant: one line per **`(meter, dimensionKey)`**. Declared dimensions come from the published Plan/SKU revision and are frozen in `pricingSnapshotRef`. Value emission on usage is an OSS/Rating contract. | +| **prorationBasis** | Day-count convention for mid-period proration: `calendar_days_actual`, `calendar_days_30`, `by_second`, `whole_unit`, or `none` (canonical enum owned by pricing `design/06`, adopted **verbatim** — CI gate `pricing.contracts.enum_drift`). Configured on the plan/price policy and frozen in `pricingSnapshotRef`; applies to ALL mid-period proration. | +| **Price eligibility** | Who may receive a `Price`/`PriceWindow`: `all_subscriptions`, `new_subscriptions_only`, or `existing_grandfathered`. Evaluated at step 2 with subscription `activatedAt` / grandfather cutover dates; within `existing_grandfathered` the **generation** is selected by the `cohort` of the subscription's pinned price id in `pricingSnapshotRef` (pricing `ADR/0002`), not `activatedAt` alone. | +| **Plan phase** | Time-bounded segment of a subscription plan (trial, intro, evergreen) with its own price schedule. Structure in Subscriptions SoR; evaluation resolves the active phase at `t` in step 1 to a **`phase_id` (uuid)** — the axis is uuid-typed (pricing D-19), never a kind-name; non-phased / one-time rows ride the plan's implicit **terminal `phase_id`**, and the kind names (trial/intro/evergreen) are display only. | +| **CatalogVersion** | Immutable, published revision of the product catalog (Catalog SoR). One component of a pricing snapshot. | +| **pricingSnapshotRef** | Immutable **composite** reference to all frozen commercial inputs needed to reproduce a charge. Canonical field list (per-segment writer): `catalogVersion` (pricing, pending→committed on `CatalogVersionPublished`) · resolved **price ids** incl. `cohort` (pricing) · evaluation-policy version (pricing) · `(currency, region)` binding (Subscriptions at activation) · resolved overlay/`priceOverlay` ids (Rating) · applied coupon id(s) + stacking policy (Rating) · FX-lock id if any (Rating) · commitment/reservation set — reservation match, pool set (incl. `poolType`, balances @ `balanceVersion`, draw order, rollover), reserved-vs-pool split (Rating). Rating is the **composition SoR** (assembles the full ref at eval). **Not** equivalent to `CatalogVersion` alone. | +| **PlanTier** | Mandatory catalog attribute on every Plan/SKU. Part of evaluation context; distinct from **OrgTier** (partner commercial projection). Primary mechanism for service-tier packaging (Basic/Pro/Enterprise) in current scope. | +| **OrgTier overlay** | Partner/reseller commercial projection applied without changing AMS tenant topology (manifest §4.1). | +| **Committed usage** | A committed quantity or spend pool (**commitment pool**, Contracts SoR) drawn down by metered usage; overage and true-up follow committed/overage rates. Two pool flavors (frozen `poolType`): `prepaid_drawdown` — the pool is billed upfront at sale (outside rating-core) and in-commit consumption is due-zero with notional lineage; `committed_rate` — in-commit consumption bills in arrears at the committed rate, with a period-end shortfall true-up. | +| **True-up obligation** | Period-end commercial adjustment surfaced as a structured `TrueUpObligation` on the evaluation result (amount, period, contract ref) for Billing — not a silent in-engine charge. | +| **Mid-cycle change** | A `PriceWindow` or overlay whose catalog `effectiveFrom` falls inside the subscriber's current invoice period (billing anchor may differ from calendar month). | +| **Retroactive pricing** | Any rule assigning a rate to usage based on a policy decision time earlier than operational processing time (late-arrival, administrative repricing). Distinct from normal effective-dated windows. | +| **PriceWindow** | Non-overlapping, UTC-bounded interval during which a `Price` row is effective. Step-2 selection is on the pricing **canonical 8-axis scope key** `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` (pricing `ADR/0001` + `ADR/0002`, adopted **verbatim**). `chargeKind` (hybrid recurring vs usage), `priceEligibility`, and `cohort` (grandfathering generation) are additive axes, so multiple rows legitimately coexist at one `(planId, currency, region, phase)`; the non-overlap invariant and 'at most one match' hold only on the **full** key. | +| **PriceOverlay** | A scoped collection of price overrides with `scope(customerGroup \| partner \| orgTier \| brand \| region \| global)` and explicit `precedence`. Eligibility resolved against evaluation-context fields before precedence stacking. | +| **Coupon** | Promotional discount instrument (id, type, validity, applicability, redemption limits, campaigns). Entity lifecycle/campaign management owned by Promotions; this PRD owns when and how an eligible coupon adjusts a resolved charge line. | +| **Coupon stacking policy** | `exclusive_best` (default — single winning coupon) or `ordered_stack` (explicit campaign-linked sequence only). | +| **RatingRule** | Defined in manifest §4.2 — maps resolved price outcome to Usage → RatedCharge in Rating. Not redefined here. | +| **SSP (Standalone Selling Price)** | Price at which an entity would sell a promised good/service separately; an input to ASC 606 allocation. Carried as references on charge lines; recognition schedules are out of scope. | + +## 2. Architecture Alignment + +| **Field** | **Value** | +|-----------|----------| +| **Applicable Manifest(s)** | BSS | +| **Relevant Chapters** | §4.1 Product and Service Catalog; §4.2 Rating and Charging; §4.4 Billing and Invoicing (snapshot/immutability contract); §2.1.3 Multi-tenant semantics; §8 Data and Domain Model (identity invariants) | + +> **Normative alignment**: extends manifest requirements for **commercial price resolution** and **deterministic rating inputs**. MUST NOT contradict: (a) Catalog as SoR for Product/SKU/Plan/Price/PriceWindow/PriceOverlay/CatalogVersion; (b) Rating as deterministic Usage→RatedCharge→BillableItem pipeline; (c) posted financial immutability with corrections via adjustments/credit/debit notes; (d) OSS/BSS boundary (BSS MUST NOT mutate OSS topology or Policy Engine state). + +> **Manifest extension (PriceWindow coverage)**: manifest §4.1 guarantees non-overlapping windows for a key; this PRD requires that key to be the pricing **8-axis canonical scope key** — `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` (pricing `ADR/0001` + `ADR/0002`) — and additionally requires **no gaps** for billable usage at `t` (if no window matches, evaluation MUST fail explicitly — AC 6). + +> **Deployment (normative for Design)**: the evaluation core (**`rating-core`**) is a **pure, I/O-free crate inside the single `rating` gear/deployable** — consolidation per [ADR-0002](./ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md), which supersedes the earlier placement note that treated the evaluation core as a logical module within the BSS Rating domain; the constraint it protected (one deployable, no separate evaluation service) is preserved and strengthened to a compiler-checked crate boundary. + +### 2.1 Terminology and Naming + +| **Name** | **Usage** | +|----------|-----------| +| **Rating** | Canonical name of this gear and its domain (manifest §4.2): the evaluation core plus the operational rating pipeline — one gear, one deployable (ADR-0002). | +| **rating-core** | The pure evaluation core (crate): deterministic price resolution (§6.3 / §17.1 steps 1–9) over frozen inputs, no I/O. Successor of "rating-core"; use at implementation/abstraction boundaries. | +| **Rating pipeline** | The operational half: usage ingestion & normalization, windowed `Q` (single-writer), usage/delta dedup, evaluation-unit synthesis & the period tick, rated-output persistence, `CommitmentBalanceEffect` publication, Billing handoff (design slices 12–16). | +| **Evaluation** (historically "evaluation") | The deterministic process resolving effective commercial prices and charge formulas for a given context (§6.3). Produces a resolved price outcome + `pricingSnapshotRef`. | +| **Tariff** | Reserved for the **Pricing gear's rate definitions** (rate-card sense: resource @ price, models, windows). Since ADR-0002 it no longer names this gear or its process. | +| **Rating / rating-core / rating-core** | Deprecated names for this gear / its core — do not use in new text (ADR-0002); historical occurrences in the evaluation slices read per the terminology bridge in [DESIGN.md](./DESIGN.md). | + +### 2.2 Predecessor PRDs and Scope Migration + +This PRD specializes or supersedes the following scope from predecessor documents: + +- **PRD-metering-pricing-module-202601120119** — "Pricing Hierarchy Orchestration (Contract > PriceOverlay > Catalog)" and "Tiered Pricing Calculator" (P0 Rating scope) move here as §6.3 evaluation order (steps 1-9) and formula definitions. The metering/collection half remains authoritative there. +- **PRD-product-catalog-marketplace-202601120119** — "Plan & Price Modeling", "Effective Dating & Price Windows", and "Price Overlays & Adjustments" define the **data primitives** this PRD evaluates; Catalog remains SoR for those primitives. Evaluation semantics and override resolution are authoritative here. +- **PRD-rating-engine-202604031200** (VHP-810) — **absorbed by this PRD (ADR-0002)**: its HIGH scope items ("Tiered pricing evaluation", "Deterministic outputs + pricingSnapshotRef") are specified here (§6), and the pipeline scope it stubbed (Usage → RatedCharge, dedup, windowed `Q`, evaluation-unit synthesis, persistence, Billing handoff) is authored as design slices 12–16 of this gear. The upstream copy is legacy provenance (not maintained). + +## 3. Actors + +### 3.1 Human Actors + +#### Product Manager + +**ID**: `cpt-cf-bss-rating-actor-product-manager` + +**Role**: Defines plans, meters, tier semantics, and effective windows so commercial behavior is explicit. +**Needs**: Tariff/price-book editor, model configuration (flat/tiered/volume/hybrid/commit), UTC windows, approval submit. + +#### Partner Admin + +**ID**: `cpt-cf-bss-rating-actor-partner-admin` + +**Role**: Applies scoped markups/discounts with precedence so channel economics are controlled. +**Needs**: OrgTier scope selection, adjustment stack definition, non-overlap validation, simulation. + +#### Finance Analyst + +**ID**: `cpt-cf-bss-rating-actor-finance-analyst` + +**Role**: Previews invoice impacts of a future window so forecasts and ASC inputs are explainable. +**Needs**: Sample-usage profiles, candidate-window selection, evaluation-trace export. + +#### Platform Operator + +**ID**: `cpt-cf-bss-rating-actor-platform-operator` + +**Role**: Owns deterministic, hierarchical price resolution so usage-based revenue is reproducible, auditable, and compatible with Rating and Finance controls. +**Needs**: Audit of rule versions, segregation of duties on publish, deterministic replay. + +### 3.2 System Actors + +#### Rating Pipeline (intra-gear) + +**ID**: `cpt-cf-bss-rating-actor-rating` + +**Role**: The operational half of this gear (design slices 12–16) — consumes the core's resolved price outcome in-process; produces `RatedCharge` / `BillableItem`; owns the Usage → RatedCharge pipeline, usage/delta dedup, windowed `Q` aggregation (single-writer per `(subscription, meter, dimensionKey, window)`), evaluation-unit synthesis incl. the period tick, rated-output persistence, and `CommitmentBalanceEffect` publication. Listed with the system actors because the evaluation-core FRs (§6) reference it as their operational counterpart; since ADR-0002 it is intra-gear, not an external system. + +#### Billing & Invoicing + +**ID**: `cpt-cf-bss-rating-actor-billing` + +**Role**: Consumes billable items + snapshots; supplies `periodState` (open / closed_posted); posts immutable invoices; executes period-level floor/cap and invoice rounding. + +#### Pricing (Product Catalog) + +**ID**: `cpt-cf-bss-rating-actor-catalog` + +**Role**: the **Pricing (Product Catalog)** gear — SoR for `skuId`, `planId`, `priceId`, `PriceWindow`, `PriceOverlay`, `CatalogVersion`. Owns the `PriceWindow` store, state machine, and UTC activation job, and **produces** the `PriceWindow*` events (pricing D-03); emits schedule-change events. Rating is a read-only consumer/resolver of these. + +#### Contracts & Agreements + +**ID**: `cpt-cf-bss-rating-actor-contracts` + +**Role**: Supplies account-specific price terms, commitments, true-up clauses, and the bounded-composition cap policy. + +#### Subscriptions + +**ID**: `cpt-cf-bss-rating-actor-subscriptions` + +**Role**: Owns effective-dated Plan/Add-on links, subscription state, plan phases, and the plan-change WHEN/asymmetry policy (`changeEffectiveAt`, `changeMode`). + +#### Promotions / Discounts + +**ID**: `cpt-cf-bss-rating-actor-promotions` + +**Role**: Owns the Coupon entity and campaign stacking; supplies frozen coupon snapshots. Rating consumes; never mutates campaigns. + +#### Finance (FX) + +**ID**: `cpt-cf-bss-rating-actor-finance-fx` + +**Role**: Owns FX rate tables and lock policies; rating-core consumes them as frozen inputs and records `fxTableVersion` / locked-rate id. + +#### OSS / AMS (Tenant Identity) + +**ID**: `cpt-cf-bss-rating-actor-oss-ams` + +**Role**: Supplies `tenantId`, delegation proofs, and OrgTier commercial projection targets. + +#### OSS Metering (Usage Dimension Population) + +**ID**: `cpt-cf-bss-rating-actor-oss-metering` + +**Role**: Emits `dimensionKey` values on each UsageRecord (e.g. S3 storage-class / region / operation; VM instance type) and normalized usage quantity. **Critical-path upstream dependency** for dimensional pricing. + +## 4. Operational Concept & Environment + +### 4.1 Module-Specific Environment Constraints + +- **Multi-tenant isolation**: price overlays and contract overrides are tenant-scoped; cross-tenant administration requires delegation proofs; a contract/account overlay MUST NOT leak across payer/seller tenant scope. +- **Time**: all effective dating and window boundaries are in **UTC**; `calendar_month` aggregation is UTC-delimited; `invoice_period` anchors to the subscription billing anchor (UTC-normalized). +- **Determinism boundary**: rating-core is a pure, I/O-free crate within the `rating` gear; it consumes frozen inputs (catalog snapshot, FX tables, coupon snapshots, windowed `Q`) and MUST NOT re-query mutable catalog state at bill-post time for posted periods. +- **Decimal precision**: rating-core emits amounts at precision sufficient for Billing; invoice rounding (per-line vs per-invoice) is applied by Billing, not rating-core. Design fixes intermediate DECIMAL precision for rating-core-emitted amounts. + +**Event alignment (manifest §4.1-4.2)**: + +- MUST consume: `PriceWindowScheduled`, `PriceWindowActivated`, `PriceWindowExpired`, `PriceWindowCancelled`, `CatalogVersionPublished` (ordering per stream). `PriceWindowCancelled` retracts a pre-cached not-yet-active window that pricing voided (retirement / cutover unwind, operator DELETE). +- MUST NOT require Rating to re-query mutable catalog state at bill-post time for posted periods; the snapshot contract remains authoritative. + +> **Gating dependency (critical path for IaaS billing)**: the **usage dimension-population contract** (OSS metering → Rating → Rating) is the bottleneck for billing real cloud resources. The BSS side is owned here (Rating admits dimensions via `dimensionKey` and freezes the declared set; Rating passes them through). The external part is **OSS metering emission** of dimension values: until OSS emits them, `dimensionKey` stays the empty tuple and the only workaround is minting a separate meter per dimension combination — exploding catalog cardinality. See §17.3 and §15. + +## 5. Scope + +### 5.1 In Scope + +| **Feature** | **Priority** | **Notes** | +|-------------|--------------|-----------| +| Deterministic evaluation API (conceptual contract) for Rating | `p1` | Resolved rate/tier outcome + `pricingSnapshotRef` + metadata; replay-safe (§6.1). | +| Pricing models: flat, per_unit (per-seat), tiered (graduated), volume (Variant A), package (block), hybrid, committed usage | `p1` | Formal semantics in §6.2; catalog `modelKind` SoR = pricing §17.2; per-seat = `quantitySource`. | +| Versioning & UTC effective dating; non-overlapping windows per manifest invariants | `p1` | Aligns with PriceWindow + PriceOverlay; activation ordered per `(tenantId, aggregateId)`. | +| Multi-currency: price currency, conversion policy, rate-lock hooks | `p1` | rating-core applies Finance FX (step 8); no tax calculation. | +| Override hierarchy: global → region/brand/orgTier/partner → customerGroup → customer/contract with explicit precedence | `p1` | §6.4 + step 4; class order `customerGroup > partner > orgTier > brand > region > global`. | +| PriceOverlay scope → tenant-axis mapping (seller/payer/brand/region) | `p1` | §6.3 / §17.1; AC 15. | +| Tier aggregation window (`Q` reset policy) for tiered/volume models | `p1` | Required on Price/plan policy; AC 14. | +| Plan phases (trial / intro / evergreen) — price resolution per active phase | `p1` | Subscriptions owns structure; step 1 + AC 16. | +| Price eligibility / grandfathering (new vs existing subscriptions) | `p1` | `priceEligibility` on PriceWindow; AC 16. | +| Billing granularity (minimum billable unit per usage price) | `p1` | Round-up before rate; step 3; AC 17. | +| Dimensional pricing — `(meter, dimensionKey)` lines | `p1` | Critical path for a real IaaS catalog; step 3 + AC 3 + AC 21. Depends on the usage dimension-population contract. | +| CAPACITY / reservation pricing (provisioned Disks/IOPS, RI-style) | `p1` | Two flavors at step 6 via `reservationMatch`: consumption (AC 22) and capacity (`capacityCharge`, AC 23). | +| Usage dimension-population contract (BSS side owned here; OSS emission external) | `p1` | Gating dependency. Rating declares/freezes; Rating passes `dimensionKey` through; OSS emits values (external). | +| Composite (derived) meter evaluation | `p1` | Formula-as-data over ≥2 published units; pricing Slice 10 delivers the primitive; §6.7. | +| Bundle `sum_of_parts` component summing + effective rev-share pass-through | `p1` | Eval-time summing; rev-share normalized at pricing publish (D-07); §9.2. | +| Coupon application in evaluation (order, stacking, tier/FX interaction) | `p2` | Promotions owns Coupon entity; semantics in §17.2; step 7; AC 18. | +| Mid-cycle price changes: bucket split, proration alignment to UTC cutoffs | `p2` | No posted invoice mutation. | +| Retroactive pricing modes: administrative re-rate → Adjustment deltas only | `p2` | Preserves invoice immutability; ties to Rating `ChargeAdjustment`. | +| ASC 606 alignment hooks: PO tags, SSP snapshot pointers, allocatable amount fields | `p2` | Recognition schedules remain Billing/Finance; Rating supplies traceable inputs. | +| Operator UX for tariff maintenance, simulation, approval thresholds | `p2` | UI screens (DESIGN, frontend). Approval workflow + audit gates are a `p1` dependency of safe evaluation (manifest §4.1 two-person rule). | + +### 5.2 Out of Scope + +- **API schemas, storage DDL, error code taxonomies** — Design document(s). +- **OSS metering** emission shapes and `UsageRecord` content beyond fields Rating consumes — OSS domain; Pricing consumes aggregated dimensions per the Rating contract. +- **Tax determination** and **statutory invoicing** — Tax Engine / Billing (rating-core MUST NOT compute tax). Handoff: the emitted amount MUST carry **discount lineage** (pre-/post-overlay, pre-/post-coupon amounts and applied ids) so Billing/Tax can choose gross-vs-net treatment; Rating supplies lineage, not ordering. +- **Full revenue recognition subledger** and **ASC 606 automated journal entries** — Finance/Billing; this PRD requires only compatible tagging and amounts. +- **Policy Engine** enforcement and **resource topology** changes — OSS. +- **Coupon / campaign lifecycle** (creation, distribution, redemption limits, fraud controls) — Promotions; Rating consumes frozen coupon definitions at evaluation time only. +- **Spend control and credit risk** — real-time spend stop / limit enforcement is OSS / Policy Engine; post-aggregation spend caps / bill-shock are Billing; credit risk and prepaid gating are Finance. Rating sets the floor/cap **amount** but performs no enforcement or gating. Launch without a hard spend ceiling requires Finance acceptance (§15). + +## 6. Functional Requirements + +> **Content boundary**: FRs define WHAT must be resolved (posting/evaluation semantics), not data models or APIs. Concrete schemas, proto definitions, error taxonomies, and mathematical formulas with symbol definitions are owned by the corresponding DESIGN (`DESIGN-tariffs-pricing-logic-*/`). The full deterministic step order (steps 1-9) is preserved normatively in §17.1. + +### 6.1 Deterministic Evaluation + +#### Deterministic evaluation API + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-deterministic-evaluation-api` + +Tariff evaluation **MUST** expose a conceptual evaluation contract that, for a given evaluation context at timestamp `t` (UTC), produces a **resolved price outcome** (effective rates, pricing model kind, tier thresholds, overlay winners) plus a `pricingSnapshotRef` and evaluation metadata. It **MUST** be replay-safe. + +**Rationale**: Rating and Finance require a stable, reproducible outcome to reproduce charges and audits. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Single outcome per frozen context (pure-function core) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-single-outcome-determinism` + +The determinism contract is stated over the **evaluation unit** (step 3): for `per_event` models a single normalized `UsageRecord`; for any model with `tierAggregationWindow != per_event`, the **window-aggregated quantity `Q`** for the `(subscription, meter, dimensionKey, window)` key — where the aggregation is the row's frozen `aggregationFunction` (`sum`, or the D-44 granule fold `peak`/`time_weighted` summed over granules — additive in every case, `fr-level-aggregation`). Given frozen inputs `(window-aggregated inputs, pricingSnapshotRef, fxTableVersion)`, the monetary outcome **MUST** be identical across replay, recompute, and cross-region batch workers. The windowed `Q` **MUST** be materialized and owned by the Rating `AggregationWindow` (single writer per partition key); Rating receives `Q` as a frozen input and **MUST NOT** aggregate. Concurrent re-resolve **MUST** serialize on the partition key. + +**Rationale**: A pure-function core over frozen, window-aggregated inputs is what makes replay and late-arrival handling non-divergent without cross-partition locks. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Snapshot carry + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-snapshot-carry` + +Every evaluation **MUST** emit identifiers sufficient for manifest `BillableItem.pricingSnapshotRef` and stable `{skuId, planId, priceId}`. `pricingSnapshotRef` **MUST** be a composite reference over the canonical field list (§1.4): the catalog-frozen subset `{catalogVersion (pending→committed), resolved price ids incl. cohort, eval-policy version}` is **pre-stamped by pricing at publish**; Rating (the **composition SoR**) adds the resolved overlay/`priceOverlay` ids, applied coupon id(s) + stacking policy, FX-lock id, and the commitment/reservation set (reservation match; pool set incl. `poolType`, balances @ `balanceVersion`, draw order, rollover; reserved-vs-pool split) at eval; Subscriptions freezes the `(currency, region)` binding at activation — **not** equivalent to `CatalogVersion` alone. + +**Rationale**: Reproducibility requires freezing all commercial inputs, not just the catalog version. + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Usage and delta idempotency + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-idempotency` + +Same usage idempotency key + same snapshot **MUST NOT** double-charge (Rating dedup remains authoritative). Deltas from retroactivity / period-FX close are **new commercial events**, not the original usage key; each delta **MUST** carry a stable correction key `(window[, slice], prior-rated-version, snapshot)` (the sub-window slice coordinate present when a §6.11 split partitions the window) so a re-rate retry is idempotent and cannot double-adjust. The owner of delta dedup (Rating or Billing) **MUST** be named in Design before the Adjustment path goes live — **named: Rating** (Design 01 §2.2 / 08 §2.2). + +**Rationale**: Deterministic replay and correction safety require distinct, stable idempotency for usage vs deltas. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Non-negative resolved price + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-non-negative-price` + +A resolved per-line price **MUST NOT** go negative; evaluation **MUST** clamp to zero or emit the residual as a structured credit (clamp-vs-credit policy TBD — §15). Applies after stacked overlays, commitment, and coupons (steps 4-7) and **before** period-level floor/cap. + +**Rationale**: Negative resolved lines corrupt downstream rating and revenue; a floor must not mask a negative line. + +**Actors**: `cpt-cf-bss-rating-actor-finance-fx` + +#### Separation from posted financials + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-separation` + +Tariff evaluation **MUST NOT** mutate Usage or posted invoices; retroactive outcomes **MUST** flow through Adjustment paths (manifest §4.2). A correcting/negative usage event **MUST** deterministically reverse its prior commercial effect (refill drawn-down commitment pool, decrement tier counter `Q` for the affected `(subscription, meter, dimensionKey, window)`) and emit compensating deltas; it **MUST NOT** drive a resolved line negative. Correction ingestion and dedup remain Rating. + +**Rationale**: Posted-financial immutability and auditable corrections are manifest invariants. + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +### 6.2 Pricing Models + +> **Model-kind SoR (pricing §17.2 kind→formula mapping, adopted verbatim):** the catalog `modelKind` enum is `{flat, per_unit, graduated, volume, package}`. `hybrid` and `committed usage` are **plan-composition / commercial constructs** (a plan carrying multiple `chargeKind` lines; a commitment pool over a base model), **not** `modelKind` values. `graduated` / `volume` / `package` are **usage-only** (`chargeKind=usage`, pricing D-18); tier bands are **always open-top** — no closed top and no above-max fail-closed branch (pricing D-17); capping is a period-level obligation (§6.11). + +#### Flat pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-flat-pricing` + +Flat charge **MUST** be `unitPrice x Q` (or a fixed amount per period for recurring); no thresholds evaluated. + +**Rationale**: The base model must be unambiguous and threshold-free. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Per-unit (per-seat) pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-per-unit-pricing` + +A per-unit recurring charge **MUST** be `unitPrice × quantity`, where `quantity` comes from the plan's `quantitySource` (`subscription_seat_count`, supplied by Subscriptions, or `manual`) — **never** metered usage `Q`. This is the catalog `modelKind=per_unit` (pricing **p1 launch**); the quantity source is frozen in `pricingSnapshotRef`. Distinct from flat (fixed per-period) and from usage-metered models. + +**Rationale**: Per-seat plans are a launch model; the seat count is an external Subscriptions input, not usage. + +**Actors**: `cpt-cf-bss-rating-actor-subscriptions` + +#### Tiered (graduated) pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-tiered-graduated` + +With two or more tiers, each unit **MUST** be charged at its marginal band rate; a single tier rate **MUST NOT** be applied to all units. With one tier only, graduated and Volume Variant A are numerically identical — the distinction is by configured model kind, not by this rule. Tier counter `Q` **MUST** use the configured `tierAggregationWindow`. + +**Rationale**: Graduated vs volume must be fixed in writing to prevent rating divergence. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Volume Variant A (rate on total Q) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-volume-variant-a` + +A single tier rate **MUST** apply to **all** units based on total quantity `Q` within the `tierAggregationWindow`; this variant **MUST** be configured explicitly per SKU and distinguishable from graduated. + +**Rationale**: Whole-quantity pricing is a distinct commercial model and must be explicit. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Package (block) pricing + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-package-pricing` + +A package (block) usage charge **MUST** be `ceil(usedQ / packageSize) × packagePrice` over the configured `tierAggregationWindow` — whole blocks are billed and a partial block rounds **up** to one block. This is the catalog `modelKind=package` (usage-only, pricing D-18); `packageSize` and `packagePrice` are frozen in `pricingSnapshotRef`. Distinct from Volume Variant A (a single per-unit rate on total `Q`). **Volume Variant B (per-tier flat block fee) is not authorable** — the catalog maps `volume` to Variant A only (pricing D Q3). + +**Rationale**: Block pricing (e.g. per 1000 API calls) is a distinct construct with round-up-to-block math; per-tier block fees have no catalog authoring home. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Level-based (gauge) aggregation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-level-aggregation` + +For a usage row whose frozen **`aggregationFunction ≠ sum`** (pricing D-44: `peak` | `time_weighted`, with `aggregationGranularity ∈ {hour, day}` — both frozen in `pricingSnapshotRef`), the meter is **level-shaped**: normalized usage records are point-stamped **gauge samples** in the level unit. Rating **MUST** derive the window quantity by the **granule fold**: the window is cut into granules per the frozen granularity; each granule folds deterministically (`peak` → the maximum sample in the granule; `time_weighted` → the step-function integral of the level over the granule, `hold_last` bounded by the declared `maxHold`, beyond which the level reads **0** and an operator signal **MUST** raise — never a guessed value); the window `Q` **MUST** equal the **sum of granule folds** and is therefore additive. All downstream machinery — the `(subscription, meter, dimensionKey, window)` counter key, supersession continuity, per-slice `bandOffsetQ` (T-D-12), band/package math, delta-only corrections — applies to this `Q` **unchanged**. A late or corrected sample **MUST** re-fold only its granule, producing a `Q` delta under the standard re-materialization discipline. Non-`sum` aggregation **MUST NOT** co-occur with a composite meter at launch. + +**Rationale**: The launch product set bills on levels — cloudlet peak-per-hour and storage GB-month — and the commercial rule (which fold, which cadence) must live in the catalog, not be pre-folded inside the emitting source (which would hide raw levels from audit and make retro re-aggregation impossible). Summing granule folds keeps `Q` additive so no counter invariant is disturbed (T-D-17). + +**Actors**: `cpt-cf-bss-rating-actor-rating`, `cpt-cf-bss-rating-actor-oss-metering` + +#### Hybrid pricing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-hybrid-pricing` + +Recurring and usage components **MUST** be emitted as **two distinct lines** under one `planId` (so Billing can itemize), each evaluated independently per its period boundaries. A hybrid "minimum commitment" **MUST** be expressed as committed-usage (commitment pool + overage, step 6); a minimum monthly invoice fee (period floor) is a separate period-level construct (§6.11, §17.2) and **MUST NOT** be conflated. Attachment points: commitment (step 6) and floor/cap attach to the usage line unless the plan marks them plan-level; coupon (step 7) attaches per `applyScope` (`usage` / `recurring` → that line; `line_total` → combined total, applied once as a plan-scoped overlay and split back pro-rata across the two lines deterministically). The attachment configuration **MUST** be frozen in `pricingSnapshotRef`. + +**Rationale**: Itemization, min-commit disambiguation, and deterministic coupon splitting are required for auditable hybrid plans. + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Committed usage + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-committed-usage` + +In-commitment and overage portions **MUST** be charged at distinct rates over the ordered `commitmentPools[]` (step 6). In-commit **billability** follows the pool's frozen `poolType`: `prepaid_drawdown` (pool billed upfront at sale, outside rating-core; in-commit consumption emits a due-zero line with notional lineage) or `committed_rate` (in-commit bills in arrears at the committed rate). Period true-up follows the contract and **MUST** be surfaced as a structured `TrueUpObligation` (amount, period, contract ref) for Billing — not an implicit posted charge; for `committed_rate` pools the shortfall formula is normative (quantity basis: unmet committed quantity × committed rate; spend basis: unmet committed spend), and `prepaid_drawdown` pools surface no pool-driven true-up (unused balance follows the rollover policy). A correcting/negative usage event **MUST** refill the drawn-down pool and emit compensating deltas; it **MUST NOT** drive the resolved line negative. + +**Rationale**: Prepaid/overage economics and true-ups must be explicit and reversible. + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +### 6.3 Rule Evaluation Order + +> The full normative step order (steps 1-9, plus the reserved-capacity and period-level phases) is preserved verbatim in §17.1. The FRs below carry the requirements that the order enforces. + +#### Deterministic evaluation order + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-evaluation-order` + +For any evaluation at `t` (UTC) and context `ctx`, the engine **MUST** apply the fixed order: (1) subscription composition + active phase, (2) base catalog row selection, (3) meter mapping + billing granularity, (4) partner/OrgTier/brand/region overlays, (5) customer/contract overlay, (6) commitment + reservation, (7) coupon, (8) FX, (9) emit. The step order is **invariant** for every contract — there is **no reordering knob**. Replay over identical inputs **MUST** be byte-identical. + +**Rationale**: A single invariant order is the basis of determinism (AC 1). + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Base catalog row selection + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-base-catalog-selection` + +Step 2 **MUST** select `Price`/`PriceWindow` such that `t in [effectiveFrom, effectiveTo)` on the pricing **8-axis canonical scope key** `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` per the non-overlap invariant. Eligibility class order is `existing_grandfathered > new_subscriptions_only > all_subscriptions` (most-specific-wins). **Within `existing_grandfathered`, the generation is selected by the subscription's bound `cohort`** — the `cohort` of its **pinned price id** in `pricingSnapshotRef` (pricing `ADR/0002`), never `activatedAt` alone. At most **one** window MUST match on the full key (coexisting hybrid `chargeKind` rows and `cohort` generations are disambiguated by the key, not fail-closed). If no eligible window matches, evaluation **MUST** fail (no silent fallback) for billable usage. When invoice currency equals the row's price currency, step 8 FX is skipped. + +**Rationale**: Gap/overlap-free, eligibility-correct selection prevents silent mispricing. + +**Actors**: `cpt-cf-bss-rating-actor-catalog` + +#### Meter mapping and billing granularity + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-meter-mapping-granularity` + +Step 3 **MUST** map `UsageRecord` to a charge line keyed by `(meter, dimensionKey)`; the mapping **MUST** be injective on `(meter, dimensionKey)` per plan revision, or reject as a configuration error (fail-closed). `billingGranularity` round-up **MUST** be applied to the **aggregated/merged measure** of the evaluation unit, **never per raw `UsageRecord`** (twelve 5-minute samples at `per_hour` MUST bill 1 hour, not 12). The merge/aggregation is owned by Rating (single-writer per `(subscription, meter, dimensionKey, window)`); Rating prices the normalized aggregate. + +**Rationale**: Injective mapping and aggregate-level round-up prevent line collisions and ephemeral over-charge. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### PriceOverlay scope → tenant-axis mapping + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-priceoverlay-scope-mapping` + +Step 4 **MUST** resolve `PriceOverlay.scope` against evaluation context: `global` always eligible; `customerGroup` matches the payer's BSS-resolved customer group at `t` (from authenticated caller claims; most-specific class); `partner` / `orgTier` match `sellerTenantId`; `brand` matches Plan/SKU `brandId` at `t`; `region` matches the usage or price-row `region` key. `resourceTenantId` **MUST NOT** alone match partner/orgTier lists; `payerTenantId` / `accountId` are used for contract/account overlays in step 5, not via `PriceOverlay.scope`. + +**Rationale**: Correct scope→axis mapping prevents cross-tenant price leakage (AC 15). + +**Actors**: `cpt-cf-bss-rating-actor-oss-ams` + +### 6.4 Override Hierarchy and Overlays + +#### Overlay stacking (partner / OrgTier / brand / region) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-overlay-stacking` + +Step 4 **MUST** apply all scope-matching `PriceOverlay` survivors as a sequential stack in a deterministic total order: ascending `precedence` (lower first); for **cross-class** ties (precedence is unique only *within* a scope class) the pricing **class-specificity order** `customerGroup > partner > orgTier > brand > region > global` (pricing `design/09`, adopted **verbatim**) governs, with ascending `priceOverlayId` only as the final within-class stable tie-break. This layer **stacks** (applies all survivors); the class order breaks ties, it does **not** pick a single winner. Equal `precedence` among lists with overlapping scope **within one class MUST** be rejected at publish validation (fail-closed); the class order + `priceOverlayId` tie-break is the runtime safety net that **MUST** still produce a single deterministic result. + +**Rationale**: Deterministic stacking with fail-closed publish validation prevents undefined precedence outcomes (AC 2). + +**Actors**: `cpt-cf-bss-rating-actor-catalog` + +#### Customer / contract overlay + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-customer-contract-overlay` + +Step 5 **MUST** apply contract/account-level overrides after step 4, bounded by entitlement and approval rules; contract terms outrank partner lists (Contract > Partner price overlays > Catalog base). Overrides **MUST NOT** introduce metering dimensions absent from the published Plan/SKU revision; contract publish validation **MUST** reject such overlays (fail-closed). Customer-layer changes **MUST NOT** silently weaken audit controls (Contract workflow + optional Finance approval). + +**Rationale**: Contract precedence and dimension integrity must hold without weakening controls. + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +#### Bounded composition (anti-drift cap) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-bounded-composition-cap` + +The cumulative markup/discount across the full partner → reseller → customer overlay chain **MUST** be bounded by a configured cap (`maxCumulativeMarkup`). When the stacked result would exceed the cap, evaluation **MUST** clamp to the cap and record the clamp in metadata (or fail-closed if the cap is marked hard); it **MUST NOT** silently compound unbounded markup. For a material multi-link chain, absence of a configured cap **MUST** be fail-closed at publish (or a Finance-set default applied); a publish-time warning is acceptable only for a single-link, non-material overlay. Default cap value and clamp-vs-fail mode are a policy decision (§15). + +**Rationale**: Unbounded markup compounding across the channel chain is a commercial-integrity risk. + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +### 6.5 Tier Aggregation, Eligibility, Phases, Granularity + +#### Tier aggregation window + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-tier-aggregation-window` + +Tiered/volume models **MUST** use the configured `tierAggregationWindow` (`calendar_month` \| `invoice_period` \| `subscription_lifetime` \| `per_event`) to govern when tier counter `Q` resets. Window boundaries: `calendar_month` in UTC; `invoice_period` anchored to the subscription billing anchor per the catalog `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}` with the no-drift month-end clamp (31→28→31, anchor day preserved; pricing D-20), frozen in `pricingSnapshotRef`. The active value **MUST** be recorded in evaluation metadata and frozen in `pricingSnapshotRef`. + +**Rationale**: Tier-counter reset policy is commercially significant and must be explicit and frozen (AC 14). + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Price eligibility and grandfathering + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-price-eligibility-grandfathering` + +Step 2 **MUST** apply `priceEligibility` with the class order `existing_grandfathered > new_subscriptions_only > all_subscriptions` (most-specific-wins, pricing `design/07`): `new_subscriptions_only` excludes subscriptions with `activatedAt` before the window `effectiveFrom`; `existing_grandfathered` includes only subscriptions activated before cutover. **Multi-generation grandfathering (pricing `ADR/0002`):** many generations — distinct `cohort`s, each an active window — may coexist on one key; Rating **MUST** select the row whose `cohort` equals the `cohort` of the subscription's **pinned price id** in `pricingSnapshotRef`, never `activatedAt` alone. If no eligible price applies, evaluation **MUST** fail (no silent fallback). + +**Rationale**: New-vs-existing eligibility and grandfathering are first-class commercial rules (AC 16). + +**Actors**: `cpt-cf-bss-rating-actor-subscriptions` + +#### Plan phases + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-plan-phases` + +Step 1 **MUST** resolve the active plan **phase** at `t` (trial / intro / evergreen or successor phases per Subscriptions SoR); the phase selects the applicable price schedule within the plan. Distinct phases MAY have schedules that coexist at the same `t` — this is not an overlap, since `phase` is part of the PriceWindow key (a uuid `phase_id`, pricing D-19). **Usage rows are phase-invariant by default (pricing D-15):** one usage row spans all phases; an explicit phase-scoped usage row wins for its phase (most-specific-wins). The no-gap rule applies to the *resolved* set — a phase covered only by a phase-invariant usage row is **not** a gap. + +**Rationale**: Phase-correct selection is required for intro/evergreen plans (AC 16). + +**Actors**: `cpt-cf-bss-rating-actor-subscriptions` + +#### Billing granularity round-up + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-billing-granularity` + +Usage duration/quantity **MUST** be rounded **up** to the configured `billingGranularity` (`per_second` \| `per_minute` \| `per_hour` \| `per_day` \| whole-unit) before rate application, on the **merged/aggregated** measure (not per raw record). `billingGranularity` **MUST** be recorded in evaluation metadata. A per-resource `minimumCharge` MAY be configured to bound ephemeral-resource over-charge (§15). + +**Rationale**: Minimum billable unit must be deterministic and applied at the aggregate (AC 17). + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +### 6.6 Commitments and Reservations + +#### Commitment drawdown, overage, true-up + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-commitment-drawdown` + +Step 6 **MUST** apply drawdown/overage per contract over an ordered list of commitment pools (`commitmentPools[]`, Contracts SoR), in declared order (waterfall): each pool absorbs quantity/spend up to its remaining balance before the next; residual beyond all pools is overage / on-demand. A single pool is the default special case. The frozen pool set, per-pool balances, draw order, rollover policy, and any reserved-vs-pool split **MUST** be carried in `pricingSnapshotRef`. Commitment is **always** evaluated at step 6 (no reordering). + +**Rationale**: Deterministic waterfall drawdown with frozen pool state is required for reproducible committed-usage billing. + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +#### Reservation pricing — consumption-flavor + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-reservation-consumption-flavor` + +When a consumption-flavor `reservationMatch` is present, the **matched portion** of measured usage **MUST** be priced at the **reserved rate** — self-service reserved rates are sourced from `pricingSnapshotRef` / the catalog snapshot; negotiated RI-style rates from the Contracts overlay at step 5 (pricing `PRD:935`) — and the remainder at on-demand rates resolved in steps 2-5. The reserved portion **MUST** be excluded from `commitmentPools[]` drawdown (reservation precedes pools) **and from the on-demand tier counter `Q`** — the remainder re-bands from zero (pricing `inst-rv-tier-q`); the in-commit pool quantity is **NOT** excluded (pool-vs-reservation asymmetry). The reservation-match identifier **MUST** be recorded in metadata and `pricingSnapshotRef`. With no `reservationMatch`, evaluation prices as pure usage. + +**Rationale**: Reserved-rate coverage of measured usage (RI-style) must be deterministic and pool-precedent (AC 22). + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +#### Provisioned-capacity charging — capacity-flavor + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-capacity-charge` + +When a capacity-flavor `reservationMatch` with `reservedQuantity` is present, evaluation **MUST** emit a `capacityCharge` = reserved rate x `reservedQuantity`, **regardless of measured usage** (zero usage still bills the allocation). The `capacityCharge` **MUST NOT** be reduced by absent usage and **MUST NOT** draw down `commitmentPools[]`. `reservedQuantity`, reserved rate, and flavor **MUST** be frozen in `pricingSnapshotRef`. + +**Rationale**: Provisioned disks/IOPS bill on allocation, not consumption (AC 23). + +**Actors**: `cpt-cf-bss-rating-actor-contracts` + +### 6.7 Dimensional (Cloud) Pricing + +#### Dimensional pricing — `(meter, dimensionKey)` lines + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-dimensional-pricing` + +Each distinct `(meter, dimensionKey)` (e.g. S3 storage-class / region / operation; VM instance type) **MUST** resolve to its own charge line and price, with no line collision (injective per the step-3 rule). The declared dimension set **MUST** be frozen in `pricingSnapshotRef`. A plan that declares no dimensions prices as a single empty-tuple line. A record arriving with empty or partial dimension values on a dimension-declaring plan **MUST NOT** be silently priced as a single line; evaluation **MUST** route it to an explicitly published default/catch-all line (if defined) or fail-closed (reject/quarantine) — never guess. + +**Rationale**: Real IaaS catalogs require per-dimension pricing without collapsing or guessing dimensions (AC 21). + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Usage dimension-population contract (BSS side) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-dimension-population-contract` + +The catalog (pricing) **persists** the declared dimension set on the Plan/SKU revision (`dimension_key`, structurally in-scope now); Rating **freezes** that declared set in `pricingSnapshotRef`; Rating passes `dimensionKey` through. Declaration authoring is catalog-owned — Rating owns only the freeze. Dimension **value pricing** stays OSS-emission-gated. Value **emission** on usage is OSS metering (external upstream requirement). Until OSS emits dimension values, `dimensionKey` stays the empty tuple and per-combination meters are the only workaround (exploding cardinality — tracked as critical-path risk, §16). + +**Rationale**: The BSS side of the dimension contract is closeable now; the OSS emission is the gating critical path. + +**Actors**: `cpt-cf-bss-rating-actor-oss-metering` + +#### Composite (derived) meter evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-composite-meter-eval` + +When a plan carries a **derived (composite) meter** — a formula-as-data over ≥ 2 published input units producing one output unit, declared and delivered by the pricing gear (Slice 10) — Rating **MUST** evaluate the frozen formula to the output quantity, then price the output unit by its `modelKind` per steps 2-9. The formula, its input unit set, and the output unit **MUST** be frozen in `pricingSnapshotRef`; Rating **MUST NOT** author or mutate the derivation (catalog-owned). Composite **input** derivation is window-`sum`; non-`sum` aggregation (`peak`/`time_weighted`, pricing D-44 / `fr-level-aggregation`) **does not co-occur** with composite meters at launch (`last`/`unique` remain Future). + +**Rationale**: A real VM line (vCPU + RAM as one priced unit) requires evaluating a catalog-declared composite formula deterministically; the upstream primitive now lands in pricing at launch. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +### 6.8 Coupons (Promotions Overlay) + +> Coupon entity lifecycle and campaign management are owned by Promotions (cross-PRD). Rating owns deterministic application semantics. Full placement/stacking/consumption contract preserved in §17.2. + +#### Coupon application order + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-coupon-application-order` + +Coupons are an overlay on resolved commercial price, applied at **step 7** after steps 4-6 (post-commitment line amount). Default: `settlementCurrency = price` coupons apply in price currency before FX (step 8); `settlementCurrency = billing` coupons apply after step 8 on the billing-currency amount (same `fxTableVersion`). The applied coupon id(s) and pre-/post-discount amounts **MUST** be recorded in metadata. + +**Rationale**: Deterministic coupon placement relative to overlays, commitment, and FX is required to reproduce charges (AC 18). + +**Actors**: `cpt-cf-bss-rating-actor-promotions` + +#### Coupon stacking and conflicts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-coupon-stacking` + +Default stacking is `exclusive_best` (select the single coupon yielding the largest customer benefit; others MUST NOT apply on the same line). `ordered_stack` applies only when a Promotions campaign explicitly links coupons with `stackSequence` (ascending; each step uses the prior output). Campaign-marked incompatible pairs **MUST** fail-closed at redemption bind time if both would apply. A coupon snapshot omitting `applyScope` (or `stackSequence` under `ordered_stack`) **MUST** fail-closed — Rating **MUST NOT** infer it. + +**Rationale**: Winner-takes vs ordered stacking must be unambiguous and fail-closed on missing policy (AC 18). + +**Actors**: `cpt-cf-bss-rating-actor-promotions` + +### 6.9 Multi-Currency and FX + +#### Multi-currency separation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-multi-currency` + +The engine **MUST** separate **price currency** (the selected `Price.amount` row; distinct per-market rows are first-class, not FX-derived), **billing currency** (invoice currency per payer account/contract), and **presentment currency** (portal display FX, non-authoritative and outside rating-core — such amounts MUST be labelled estimates). Conversion applies only when billing currency != row currency. + +**Rationale**: Conflating list price, settlement currency, and display FX causes disputes. + +**Actors**: `cpt-cf-bss-rating-actor-finance-fx` + +#### FX policy (rating-core abstraction) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-fx-policy` + +When invoice currency != price currency, rating-core **MUST** apply the FX table per Finance policy and record `fxTableVersion` or locked-rate id; it **MUST NOT** use implicit/provider-default FX without a policy record. Two deterministic policies: (a) **per-window rate-lock** — final at event time; (b) **invoice-period FX** — emit a **provisional** amount at the locked/spot rate (flagged provisional) on the hot path and **re-rate by delta at period close** via the Adjustment path (close-time `fxTableVersion` is authoritative). Replay over identical inputs (including which `fxTableVersion` applied at which stage) **MUST** be byte-identical. + +**Rationale**: Explicit, recorded FX with provisional+delta close keeps the hot path fast and replay byte-identical (AC 8). + +**Actors**: `cpt-cf-bss-rating-actor-finance-fx` + +### 6.10 Retroactivity and Corrections + +#### Posted-period protection + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-posted-period-protection` + +When `periodState = closed_posted`, a retroactive price change to usage in that period **MUST NOT** alter posted invoice lines and **MUST** generate **delta** adjustments consumable by Billing per immutability rules. Retroactive runs **MUST** separately record usage-observation time and pricing-policy decision time in the audit log. + +**Rationale**: Posted financials are immutable; corrections flow as auditable deltas (AC 9). + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Late-arriving usage into an aggregate window + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-late-arriving-usage-reresolve` + +For a graduated/volume model over `tierAggregationWindow != per_event` with `periodState = open`, late usage arriving after some events were rated **MUST** trigger deterministic re-resolution of tier placement for the whole window-aggregated `Q` and emit **DELTA** adjustments for already-rated events (no mutation of prior outputs), re-resolved **strictly from the pinned `pricingSnapshotRef`** (no live catalog read; when a §6.11 split partitions the window, per sub-window slice — each slice replays its **own** pin, coupled to earlier slices only via the frozen band offset). With `periodState = closed_posted`, the correction follows posted-period protection. A missing `periodState` **MUST** fail-closed (no guessing). + +**Rationale**: Open-window late arrivals must re-resolve deterministically without mutating prior outputs (AC 19). + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +#### Usage corrections / negative quantity + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-usage-corrections` + +A correcting/negative usage event **MUST** deterministically reverse its prior commercial effect: refill the drawn-down commitment pool and decrement tier counter `Q` for the affected `(subscription, meter, dimensionKey, window)`, emitting compensating deltas. It **MUST NOT** drive a resolved line negative. Correction ingestion and dedup remain Rating. + +**Rationale**: Reversals must be deterministic and non-negative to keep commitment and tier state correct. + +**Actors**: `cpt-cf-bss-rating-actor-rating` + +### 6.11 Period-Level and Plan-Change Obligations + +> These are period-level phases outside the per-line step order (steps 1-9 have no slot). Full boundary contracts in §17.2. + +#### Period-level floor/cap obligation + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-period-floor-cap-obligation` + +Minimum fee (floor) and maximum charge (cap) per period are **period-level** phases over the aggregated total, applied **after** per-line emission (step 9). Rating **MUST** set the floor/cap amount, currency, and attachment scope and emit a structured `PeriodFloorCapObligation` (amount, comparison basis, period, contract/plan ref); **Billing executes** it (`max(total, floor)` / `min(total, cap)`) during period aggregation. rating-core **MUST NOT** apply the min/max at line aggregation or round. The non-negative guard applies to each line **before** floor/cap; a floor **MUST NOT** mask a negative line. Whether a contractual floor claws back coupon discount is unresolved (§15; default proposal: floor compares post-coupon total). + +**Rationale**: Period-level min/max must be reserved as a Billing-executed obligation, not a per-line op. + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Mid-cycle proration + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-mid-cycle-proration` + +When a `PriceWindow` activates during an invoice period, charges **MUST** be computed separately for each sub-window with distinct snapshots, each emitted at **full precision** (no invoice rounding); Billing aggregates and rounds. Any recurring component prorated across the boundary **MUST** use the configured `prorationBasis` frozen in `pricingSnapshotRef`. + +**Rationale**: Mid-cycle window changes must split deterministically and defer rounding to Billing (AC 7). + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Plan-change proration + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-plan-change-proration` + +On a plan change at `changeEffectiveAt`, evaluation **MUST** rate planA over `[periodStart, changeEffectiveAt)` and planB over `[changeEffectiveAt, periodEnd)` (half-open, UTC) against each plan's own revision and snapshot, each at full precision (Billing aggregates). The recurring component **MUST** be prorated on the configured `prorationBasis`. Tier `Q` and commitment-pool carry-vs-reset across the boundary **MUST** follow snapshot-frozen configuration. Corrections to an already-rated portion **MUST** be emitted as deltas. Evaluation **MUST** consume `(changeEffectiveAt, changeMode)` and **MUST NOT** decide the change mode (Subscriptions owns the policy). + +**Rationale**: Plan-change splits must be deterministic and consume — not decide — the change mode (AC 20). + +**Actors**: `cpt-cf-bss-rating-actor-subscriptions` + +### 6.12 Governance and ASC 606 Traceability + +#### ASC 606 traceable identifiers + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-fr-asc606-traceable-identifiers` + +The resolved price outcome **MUST** always include `performanceObligationRef` and `sspSnapshotPointer` fields (nullable when not applicable). Non-null values **MUST** be immutable once emitted — subsequent catalog changes **MUST NOT** alter an emitted reference. Billing/Finance MAY ignore null fields. + +**Rationale**: Downstream revenue allocation requires stable, immutable PO/SSP references — not a recognition engine here (AC 10). + +**Actors**: `cpt-cf-bss-rating-actor-billing` + +#### Publish approval and audit governance + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-fr-publish-approval-governance` + +The two-person approval workflow for material catalog publish is owned by the **pricing Slice 5** approval engine (single engine — Rating does **not** run a second workflow, manifest §4.1 two-person rule). Rating **MUST** register its four publish-time checks as **fail-closed validators** in that pipeline — ambiguous precedence, ambiguous meter mapping, missing anti-drift cap on material chains, and contract overlays introducing undeclared dimensions — and **MUST** emit auditable events with actor, before/after references, and effective times. + +**Rationale**: Safe evaluation depends on segregation of duties and fail-closed publish gates before production. + +**Actors**: `cpt-cf-bss-rating-actor-platform-operator` + +## 7. Non-Functional Requirements + +### 7.1 NFR Inclusions + +> Targets below are **working assumptions** (baselines from `PRD-metering-pricing-module-202601120119`) pending the program NFR workshop; rows marked TBD MUST be committed before Design lock (§15). + +#### Throughput and latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-nfr-throughput-latency` + +Tariff evaluation **MUST** meet p95 latency targets: **< 100 ms** for catalog price lookup and **< 1 s** for the overall rating path; hot-path throughput **MUST** sustain **>= 10M events/day/region**. + +**Threshold**: p95 <= 100 ms catalog lookup; p95 < 1 s overall rating path; >= 10M events/day/region (working assumption; final acceptance at NFR workshop, date TBD). + +**Rationale**: Rating is on the monetization critical path; delays become revenue leakage or disputes. + +#### Horizontal scale (no cross-partition locks) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-nfr-horizontal-scale` + +Horizontal scaling **MUST** avoid cross-partition locks on the evaluation hot path; ordering is per-partition `(subscription, meter, dimensionKey, window)`, not global. + +**Threshold**: Zero cross-partition locks on the hot path; per-partition ordering only. + +**Rationale**: Cross-partition locking caps throughput and breaks the >= 10M events/day/region target. + +#### Audit completeness and segregation of duties + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-nfr-audit-segregation` + +Material catalog publishes/overrides run through the **pricing Slice 5** multi-approver workflow (single engine); Rating's registered fail-closed validators **MUST** run on every such publish and **MUST** emit auditable events with actor, before/after references, and effective times. + +**Threshold**: 100% of material publishes carry pricing Slice 5 multi-approver sign-off, Rating's validators run fail-closed, and a complete before/after audit event. + +**Rationale**: CFO-grade controls and partner trust require segregation of duties and complete audit. + +#### Resilience (fail-safe, idempotent retries) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-nfr-resilience` + +When evaluation cannot read a consistent snapshot, it **MUST** fail safe (no partial pricing); retries **MUST** be idempotent. + +**Threshold**: Zero partial/best-guess priced outputs under read-model lag; idempotent retry on transient failure. + +**Rationale**: Financial correctness requires fail-closed behavior, never best-guess pricing (AC 13). + +### 7.2 NFR Exclusions + +Explicit dispositions for domains not owned by this PRD (no silent omissions): + +- **Tax computation NFRs**: Not applicable — owned by Tax Engine / Billing; rating-core MUST NOT compute tax. +- **Revenue recognition schedule performance**: Not applicable — Finance/Billing own recognition; this PRD supplies tagging/amounts only. +- **Spend-enforcement / real-time stop latency**: Not applicable — OSS / Policy Engine (real-time stop), Billing (post-aggregation cap), Finance (credit risk); Rating sets the amount, performs no enforcement. +- **Frontend UX performance / accessibility (WCAG) / i18n**: Not applicable to this backend PRD — owned by the corresponding frontend DESIGN. + +## 8. Five Quality Vectors Analysis + +| **Quality Vector** | **Show-Stopper Requirements** | **Rationale** | +|--------------------|-------------------------------|---------------| +| **Efficiency** | Evaluation MUST be cache-friendly (read models, immutable snapshots) and avoid repeated full catalog scans per usage event. | Usage pipelines are volume-heavy; CPU/IO waste raises unit cost of goods sold for cloud metering. | +| **Reliability** | Outcomes MUST be replay-deterministic; failures MUST be explicit (fail-closed), never best-guess pricing. | Financial correctness and partner trust require reproducible charges and defensible audits. | +| **Performance** | Hot-path and batch rating MUST scale horizontally per tenant/partition with bounded p95 latency under peak OSS usage (targets in §7.1). | Rating is on the monetization critical path; delays become revenue leakage or disputes. | +| **Security** | Tenant isolation for price overlays and contract overrides; delegation proofs for cross-tenant administration; immutable audit for changes. | Pricing data is commercially sensitive; cross-tenant leakage is a critical incident class. | +| **Versatility** | The model matrix (flat/per_unit/tiered/volume/package/hybrid/commitment) and overlay hierarchy MUST extend without breaking snapshot contracts to Rating. | Channel business models evolve; rigid pricing cores force expensive parallel systems. | + +## 9. Public Library Interfaces + +> Rating is a backend pricing module (rating-core within the Rating domain), not a client library. Interfaces below are high-level contracts; concrete API schemas, endpoints, and DDL belong in DESIGN. + +### 9.1 Public API Surface + +#### Evaluation contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-tariff-evaluation` + +**Type**: conceptual evaluation contract (shape in Design) + +**Stability**: stable (contract intent), schema unstable (Design owns) + +**Description**: Given an evaluation context at `t`, returns a resolved price outcome (rates, model kind, tier thresholds, overlay winners), `pricingSnapshotRef`, discount lineage, and evaluation metadata (applied coupons, `tierAggregationWindow`, `fxTableVersion`, granularity). Replay-safe and deterministic. + +**Breaking Change Policy**: Major version bump for incompatible request/response changes; snapshot semantics are part of the contract. + +### 9.2 External Integration Contracts + +#### Rating handoff contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-contract-rating-handoff` + +**Direction**: provided by Rating to Rating + +**Protocol/Format**: resolved price outcome + `pricingSnapshotRef` + obligations (`TrueUpObligation`, `PeriodFloorCapObligation`); Rating maps to RatedCharge / BillableItem (Design). + +**Compatibility**: Snapshot-referenced and replay-safe; Rating owns Usage → RatedCharge pipeline, dedup, and windowed `Q`. + +#### Finance FX input contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-contract-finance-fx-input` + +**Direction**: required from Finance + +**Protocol/Format**: FX rate tables and lock policies with `fxTableVersion`; per-window rate-lock and invoice-period FX modes (Design). + +**Compatibility**: Immutable frozen inputs; rating-core records `fxTableVersion` / locked-rate id; no implicit provider defaults. + +#### Promotions coupon snapshot contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-contract-promotions-coupon` + +**Direction**: required from Promotions + +**Protocol/Format**: frozen coupon snapshot (`couponId`, `adjustmentType`, `value`, `settlementCurrency`, `applyPerTierBand`, `applyScope`, `stackSequence`, validity, applicability, redemption eligibility) (Design). + +**Compatibility**: Fail-closed on missing `applyScope` / `stackSequence` under `ordered_stack`; Rating never infers coupon rules from mutable campaign UI state. + +#### Billing periodState / obligation contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-contract-billing-periodstate` + +**Direction**: bidirectional with Billing + +**Protocol/Format**: Billing supplies `periodState` (open / closed_posted); Rating emits `PeriodFloorCapObligation` and full-precision sub-window amounts; Billing aggregates, applies floor/cap, and rounds (Design). + +**Compatibility**: rating-core MUST NOT round or apply period-level min/max; Billing owns aggregation and rounding policy id. + +#### Catalog / Pricing read-model input contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-contract-pricing-readmodel` + +**Direction**: required from the Pricing (Product Catalog) gear + +**Protocol/Format**: the frozen read-model consumer contract (pricing `design/06`): the canonical 8-axis scope key, `modelKind` → formula mapping (pricing §17.2), tier bands, `priceEligibility` + `cohort` (generation), the `prorationBasis` and `billingAnchorPolicy` enums adopted **verbatim**, the prepaid-grant set, and `{skuId, planId, priceId}` rating-compatibility; PriceWindow state via `PriceWindow*` events (incl. `PriceWindowCancelled`); bundle `sum_of_parts` component sets with **normalized effective rev-shares** (`effective_share_bp`) (Design). + +**Compatibility**: adopted **verbatim** (CI gate `pricing.contracts.enum_drift` on the enums); Rating re-resolves open-period corrections strictly from the pinned `pricingSnapshotRef` (no live catalog read); Rating **sums** `sum_of_parts` components at eval and passes effective rev-shares through **untouched** (rev-share normalization is pricing publish-time, D-07); Rating registers its four publish-time checks as fail-closed validators in the pricing Slice 5 approval pipeline (single engine, not a second workflow). + +#### Subscriptions input contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-contract-subscriptions-input` + +**Direction**: required from Subscriptions + +**Protocol/Format**: active plan phase (`phase_id`) at `t`; `priceEligibility` inputs (`activatedAt`, bound `cohort` via the pinned price id); `quantitySource` seat count for `per_unit`; the plan-change `(changeEffectiveAt, changeMode)` policy (Design). + +**Compatibility**: Rating consumes — never decides — the change mode; the `(currency, region)` binding is frozen by Subscriptions into `pricingSnapshotRef` at activation. + +## 10. Use Cases + +#### Tariff and price-book editing + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-usecase-tariff-editor` + +**Actor**: `cpt-cf-bss-rating-actor-product-manager` + +**Preconditions**: +- A published Catalog version with SKUs/Plans exists. + +**Main Flow**: +1. Select SKU/Plan. +2. Configure model (flat / tiered / volume / hybrid / commit) and tier semantics. +3. Set UTC effective windows and submit for approval. + +**Postconditions**: +- A versioned tariff is staged with explicit commercial behavior, pending approval. + +**Alternative Flows**: +- **Ambiguous precedence or meter mapping**: publish validation rejects fail-closed. + +#### Partner price-overlay management + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-usecase-partner-priceoverlay` + +**Actor**: `cpt-cf-bss-rating-actor-partner-admin` + +**Preconditions**: +- An OrgTier / partner scope exists for the seller tenant. + +**Main Flow**: +1. Select OrgTier scope. +2. Define the adjustment stack with explicit precedence. +3. Validate non-overlap and simulate against sample usage. + +**Postconditions**: +- A scope-filtered `PriceOverlay` is staged with deterministic precedence. + +**Alternative Flows**: +- **Equal precedence with overlapping scope**: rejected at publish (fail-closed). + +#### Finance simulation of a future window + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-usecase-finance-simulation` + +**Actor**: `cpt-cf-bss-rating-actor-finance-analyst` + +**Preconditions**: +- A candidate `PriceWindow` and a sample usage profile are available. + +**Main Flow**: +1. Upload/select a sample usage profile. +2. Pick the candidate window. +3. Export the evaluation trace (rates, overlays, snapshot, ASC inputs). + +**Postconditions**: +- Forecast and ASC inputs are explainable from a reproducible trace. + +## 11. User Interaction and Design + +| **Interface Name** | **Role** | **Steps** | **Mockup Screen** | +|--------------------|----------|-----------|-------------------| +| Tariff / price book editor | As a Product Manager, I define plans, meters, tier semantics, and effective windows so commercial behavior is explicit | 1. Select SKU/Plan
2. Configure model (flat/tiered/volume/hybrid/commit)
3. Set UTC windows and approval submit | — | +| Partner price overlay manager | As a Partner Admin, I apply scoped markups/discounts with precedence so channel economics are controlled | 1. Select OrgTier scope
2. Define adjustment stack
3. Validate non-overlap and simulate | — | +| Finance simulation | As a Finance Analyst, I preview invoice impacts of a future window so forecasts and ASC inputs are explainable | 1. Upload/select sample usage profile
2. Pick candidate window
3. Export evaluation trace | — | + +## 12. Acceptance Criteria + +> **As a** platform operator **I want** deterministic, hierarchical price resolution **so that** usage-based revenue is reproducible, auditable, and compatible with Rating and Finance controls. + +### Price resolution and determinism + +**1. Single outcome per frozen context** +- **Given** a fixed evaluation context and frozen `pricingSnapshotRef` inputs +- **When** two workers evaluate the same usage record +- **Then** they MUST produce identical resolved unit rates and pre-tax monetary amounts before the Billing tax stage +- **And** all divergences without input change MUST be treated as defects + +**2. Hierarchy application order** +- **Given** global, partner `PriceOverlay`, and customer contract overrides that all apply to the same `planId` +- **When** evaluation resolves price at `t` +- **Then** overrides MUST apply in the order defined in §17.1, the partner-layer stack ordered by ascending `PriceOverlay.precedence` +- **And** the evaluation MUST emit an audit trail of which layer produced the winning values +- **Given** two `PriceOverlay` rows with overlapping scope and equal `precedence` +- **When** publish validation runs +- **Then** publish MUST be rejected (fail-closed); if such a pair reaches runtime, evaluation MUST apply the deterministic cross-class class-specificity order `customerGroup > partner > orgTier > brand > region > global`, then `priceOverlayId` as the within-class final tie-break, and MUST NOT produce an undefined result + +**3. Meter ambiguity rejection** +- **Given** a plan/SKU publish where a single `(meter, dimensionKey)` maps to more than one charge line +- **When** Catalog publish validation runs +- **Then** publish MUST be rejected (fail-closed) before reaching production +- **Given** a contract overlay that introduces a metering dimension absent from the published Plan/SKU revision +- **When** contract publish validation runs +- **Then** publish MUST be rejected (fail-closed) per step 5 +- **Given** an invalid configuration that reached runtime +- **When** evaluation processes usage for that plan revision +- **Then** evaluation MUST fail-closed and MUST NOT silently pick a default tier + +### Pricing models + +**4. Graduated vs volume semantics** +- **Given** a tiered SKU configured as graduated with two or more tiers +- **When** `Q` spans multiple tiers +- **Then** charge MUST equal the marginal sum per the graduated rule +- **Given** the same numeric tiers configured as volume Variant A +- **When** `Q` is in tier `k` +- **Then** charge MUST apply `P_k` to the entire `Q` +- **Given** a SKU with only one tier +- **When** evaluation runs as graduated or volume Variant A +- **Then** the monetary outcome MAY be identical; the configured model kind MUST still be persisted in metadata +- **Given** `tierAggregationWindow = calendar_month` and usage in March and April +- **When** tier selection runs for April usage +- **Then** March usage MUST NOT count toward the April tier counter `Q` + +**5. Committed usage** +- **Given** a subscription with committed quantity `C_commit` and an overage rate for the period +- **When** measured usage `Q` exceeds `C_commit` +- **Then** evaluation MUST split usage into in-commit and overage portions +- **And** when the contract defines period-end true-up, MUST emit a `TrueUpObligation` (amount, period, contract reference) consumable by Billing — not an implicit posted charge + +**5a. Per-unit (per-seat) pricing** +- **Given** a `per_unit` plan with `unitPrice` and `quantitySource = subscription_seat_count` +- **When** the subscription reports `quantity = N` seats at `t` +- **Then** the recurring charge MUST equal `unitPrice × N` (never metered usage `Q`); the frozen `quantitySource` MUST be recorded in `pricingSnapshotRef` (joint pricing fixture — FixtureGate) + +**5b. Package (block) pricing** +- **Given** a `package` usage SKU with `packageSize` and `packagePrice` +- **When** `usedQ` in the `tierAggregationWindow` requires `ceil(usedQ / packageSize)` blocks +- **Then** the charge MUST equal `ceil(usedQ / packageSize) × packagePrice` (a partial block rounds up to one block); distinct from Volume Variant A (joint pricing fixture — FixtureGate) + +**5c. Composite (derived) meter evaluation** +- **Given** a plan carrying a pricing-declared derived meter (formula-as-data over ≥ 2 published input units, `aggregationFunction = sum`) +- **When** evaluation runs at `t` +- **Then** Rating MUST evaluate the frozen formula to the output quantity, then price the output unit by its `modelKind`; the formula + input unit set + output unit MUST be frozen in `pricingSnapshotRef` and MUST NOT be authored by Rating (joint pricing fixture — FixtureGate) + +**5d. Level-based (gauge) aggregation — D-44 / T-D-17** +- **Given** a usage row with frozen `aggregationFunction = peak` (or `time_weighted`) and `aggregationGranularity = hour`, and gauge samples for the window's granules (including one granule with a late-arriving higher sample and one granule with a sampling gap exceeding `maxHold`) +- **When** the window `Q` is derived and the late sample then re-folds its granule +- **Then** `Q` MUST equal the **sum of granule folds** (`peak`: max sample per granule; `time_weighted`: step-integral with `hold_last` bounded by `maxHold` — the gapped granule reads **0** for the uncovered span and an operator signal MUST raise, never a guessed level) +- **And** the late sample MUST change only its own granule's fold, producing a `Q` delta under the standard re-materialization (new `qVersion`, delta-only re-resolution); band/package math and `bandOffsetQ` slice math over this `Q` MUST be unchanged from the `sum` case (joint pricing fixture — FixtureGate; publish of a non-`sum` row is blocked without it) + +### Time, versioning, currency + +**6. Effective windows** +- **Given** only non-overlapping `PriceWindow` rows for the 8-axis canonical scope key `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` +- **When** time `t` is queried for base catalog selection per step 2 +- **Then** at most one window MUST match **on the full key** — coexisting `chargeKind` rows (hybrid recurring/usage) and grandfathering `cohort` generations are disambiguated by the key, not a fail-close +- **And** distinct phases, `chargeKind`s, and `cohort`s MAY hold schedules that coexist at the same `t` — not an overlap, since each is part of the key +- **And** if none match, evaluation MUST fail explicitly for billable usage (no silent fallback) + +**7. Mid-cycle activation** +- **Given** a `PriceWindow` activating at `effectiveFrom` during an invoice period +- **When** usage spans the boundary +- **Then** charges MUST be computed separately for each sub-window with distinct snapshots, each emitted without invoice rounding (full precision) +- **And** the invoice-period total MUST be computed by Billing as the sum of sub-window amounts followed by Billing's rounding policy — rating-core MUST NOT round at aggregation +- **And** any prorated recurring component MUST use the configured `prorationBasis` frozen in `pricingSnapshotRef` + +**8. Multi-currency (rating-core FX abstraction)** +- **Given** price currency differs from invoice currency +- **When** rating-core applies conversion per step 8 +- **Then** the FX table version or locked rate id MUST be recorded in the evaluation result +- **And** conversion MUST NOT use implicit provider defaults without a policy record + +### Retroactivity and corrections + +**9. Posted period protection** +- **Given** an invoice already posted for period `P` +- **When** a retroactive price change is applied to usage in `P` +- **Then** the system MUST NOT alter posted invoice lines +- **And** MUST generate delta adjustments consumable by Billing per immutability rules +- **And** retroactive runs MUST separately record usage-observation time and pricing-policy decision time in the audit log + +**10. Late-arriving usage into an aggregate window** +- **Given** a model priced over `tierAggregationWindow != per_event` and `periodState = open` +- **When** usage arrives late into that window after some events were rated +- **Then** evaluation MUST deterministically re-resolve tier placement for the whole window-aggregated `Q` and emit DELTA adjustments for already-rated events (no mutation of prior outputs) +- **Given** `periodState = closed_posted` +- **Then** the correction MUST follow posted-period protection (delta adjustments only) +- **And** a missing `periodState` MUST fail-closed (no guessing) + +### ASC 606 traceability + +**11. ASC 606 traceable identifiers** +- **Given** a evaluation that produces a charge for a subscription +- **When** the result is emitted to Rating / Billing +- **Then** the resolved outcome MUST always include `performanceObligationRef` and `sspSnapshotPointer` (nullable when not applicable) +- **And** non-null values MUST be immutable once emitted — subsequent catalog changes MUST NOT alter an emitted reference + +### Tier aggregation, overlays, and eligibility + +**12. Tier aggregation window** +- **Given** a tiered or volume SKU with `tierAggregationWindow = invoice_period` +- **When** usage events occur in two sub-periods of the same invoice period with quantities `Q1` and `Q2` +- **Then** tier counter `Q` MUST equal `Q1 + Q2` within that invoice period (not reset per event unless `per_event`) +- **And** the active `tierAggregationWindow` value MUST be recorded in metadata and `pricingSnapshotRef` + +**13. PriceOverlay scope and tenant axes** +- **Given** a partner `PriceOverlay` scoped to `sellerTenantId = Partner-A` and a context with `sellerTenantId = Partner-B` +- **When** evaluation runs step 4 +- **Then** the Partner-A list MUST NOT apply (filtered before precedence stacking) +- **Given** a contract/account overlay bound to a specific `payerTenantId` / `accountId` +- **When** evaluation runs for a different payer/account +- **Then** that overlay MUST NOT apply and MUST NOT leak across tenants + +**14. Plan phase and grandfathering** +- **Given** a subscription in intro phase until `2026-04-30` and evergreen from `2026-05-01` +- **When** usage at `t = 2026-04-15` is evaluated +- **Then** intro-phase prices MUST apply; evergreen prices MUST NOT +- **Given** a `PriceWindow` with `priceEligibility = new_subscriptions_only` effective `2026-04-01` +- **When** subscription `activatedAt = 2026-01-01` is rated at `t = 2026-04-15` +- **Then** that window MUST NOT apply; a prior grandfathered window or explicit eligibility row MUST apply, or evaluation MUST fail if no eligible price +- **Given** two `existing_grandfathered` generations (distinct `cohort`s, each an active window at `t`) and a subscription whose pinned price id in `pricingSnapshotRef` carries `cohort = C1` +- **When** the subscription is rated at `t` +- **Then** evaluation MUST select the row whose `cohort = C1` (never by `activatedAt` alone); most-specific-wins orders eligibility classes only + +**15. Billing granularity** +- **Given** a usage price with `billingGranularity = per_hour` and raw duration `65 seconds` +- **When** evaluation computes chargeable quantity +- **Then** billable quantity MUST be 1 hour (round up), not 65 seconds +- **And** `billingGranularity` MUST be recorded in metadata +- **Given** twelve fragmented 5-minute records for one continuous hour of the same `(meter, dimensionKey)` +- **When** evaluation computes chargeable quantity +- **Then** round-up MUST apply to the merged measure (1 hour billable) — NOT per-record round-up (which would yield 12 hours) + +### Promotions and coupons + +**16. Coupon application order and stacking** +- **Given** a resolved line after steps 4-6 with partner and contract overlays applied +- **When** two eligible coupons match the same line and stacking policy is `exclusive_best` +- **Then** exactly one coupon MUST apply — the one yielding the lowest charge +- **And** the result MUST record `couponId`, stacking policy, and pre-/post-discount amounts +- **Given** campaign-linked `ordered_stack` with sequence `[C1, C2]` +- **Then** C2 MUST apply to the amount produced after C1 +- **Given** a graduated tier line total of 100 and a 10% coupon without `applyPerTierBand` +- **Then** the discount MUST be 10 on the line total, not per marginal band +- **Given** price currency EUR and billing currency USD with a price-currency coupon +- **Then** the coupon MUST apply at step 7 before FX; billing-currency coupons MUST apply only after step 8 + +### Plan change and proration + +**17. Plan-change proration within a period** +- **Given** a subscription changes from planA to planB at `changeEffectiveAt` inside one billing period +- **When** evaluation rates the period +- **Then** it MUST rate planA over `[periodStart, changeEffectiveAt)` and planB over `[changeEffectiveAt, periodEnd)` (half-open, UTC) against each plan's own revision and snapshot +- **And** each sub-window MUST be emitted at full precision (Billing aggregates) +- **And** the recurring component MUST be prorated on the configured `prorationBasis` frozen in `pricingSnapshotRef` +- **And** tier `Q` and commitment-pool carry-vs-reset across the boundary MUST follow the snapshot-frozen configuration +- **And** corrections to an already-rated portion MUST be emitted as deltas via the Adjustment path +- **And** evaluation MUST consume `(changeEffectiveAt, changeMode)` and MUST NOT decide the change mode + +### Cloud resource pricing + +**18. Dimensional pricing** +- **Given** a meter with declared dimensions (e.g. S3 storage-class / region / operation) and dimension values present on the usage record +- **When** evaluation maps usage at `t` (step 3) +- **Then** each distinct `(meter, dimensionKey)` MUST resolve to its own charge line and price, with no line collision +- **And** the declared dimension set MUST be frozen in `pricingSnapshotRef` +- **Given** a plan that declares dimensions but a record arrives with empty or partial dimension values +- **Then** the record MUST NOT be silently priced as a single line; evaluation MUST route it to an explicitly published default/catch-all line (if defined) or fail-closed — never guess + +**19. Reservation pricing — consumption-flavor** +- **Given** a consumption-flavor `reservationMatch` covering part of the measured usage at `t` +- **When** evaluation runs step 6 +- **Then** the matched portion MUST be priced at the reserved rate and the remainder at on-demand rates from steps 2-5 +- **And** the reserved portion MUST be excluded from `commitmentPools[]` drawdown +- **And** the matched quantity MUST be excluded from the on-demand tier counter `Q` (the remainder re-bands from zero); the in-commit pool quantity is NOT excluded (pool-vs-reservation asymmetry) +- **And** the reservation-match identifier MUST be recorded in metadata and `pricingSnapshotRef` +- **Given** no `reservationMatch` is present +- **Then** evaluation MUST price as pure usage + +**20. Provisioned-capacity charging — capacity-flavor** +- **Given** a capacity-flavor `reservationMatch` with `reservedQuantity` (e.g. 100 GB disk) at `t` +- **When** evaluation runs step 6 and measured usage is zero for the period +- **Then** evaluation MUST emit a `capacityCharge` = reserved rate x `reservedQuantity` (allocation billed regardless of usage) +- **And** the `capacityCharge` MUST NOT be reduced by absent usage and MUST NOT draw down `commitmentPools[]` +- **And** `reservedQuantity`, reserved rate, and flavor MUST be frozen in `pricingSnapshotRef` + +### Non-Functional Requirements (Show-Stoppers) + +**1. Throughput and latency** +- **Given** peak usage ingestion rates per tenant partition +- **When** evaluation runs on the hot path or batch rating +- **Then** p95 latency MUST meet working-assumption targets (< 100 ms catalog lookup, < 1 s overall rating path) and hot-path throughput MUST sustain >= 10M events/day/region +- **And** horizontal scaling MUST avoid cross-partition locks on the hot path + +**2. Audit and segregation** +- **Given** a material tariff publish or override +- **When** the change is committed +- **Then** the operation MUST route through the pricing Slice 5 multi-approver workflow (single engine) with Rating's validators run fail-closed, per manifest §4.1 +- **And** MUST emit auditable events with actor, before/after references, and effective times + +**3. Resilience** +- **Given** transient downstream read-model lag +- **When** evaluation cannot read a consistent snapshot +- **Then** evaluation MUST fail safe (no partial pricing) +- **And** retries MUST be idempotent + +## 13. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| OSS / AMS (tenant identity & hierarchy) | `tenantId`, delegation proofs, OrgTier commercial projection targets | `p1` | +| Pricing (Product Catalog) | Published `skuId`, `planId`, `priceId`, `PriceWindow`, `PriceOverlay`, `CatalogVersion`; owns PriceWindow store/state-machine/activation + `PriceWindow*` events (D-03); schedule-change events | `p1` | +| OSS metering / Rating (usage dimension population) | `dimensionKey` values on each UsageRecord; normalized usage quantity (values NOT produced here — declared/frozen here) | `p1` | +| Contracts & Agreements | Account-specific price terms, commitments, true-up clauses, anti-drift cap policy | `p1` | +| Subscriptions | Effective-dated Plan/Add-on links, subscription state, plan phases, `(changeEffectiveAt, changeMode)` | `p1` | +| Rating & Charging | Consumes resolved price outcome + Usage; owns Usage → RatedCharge, dedup, windowed `Q`. Downstream PRD `PRD-rating-engine-202604031200` is draft/empty; contract TBD | `p1` | +| Billing & Invoicing | Supplies `periodState`; consumes billable items + snapshots; posts immutable invoices; executes floor/cap and rounding | `p1` | +| Finance (FX) | FX rate tables and lock policies; `fxTableVersion` | `p1` | +| Promotions / Discounts | Published Coupon definitions, redemption state, campaign stacking links (TBD PRD) | `p2` | +| Spend control / credit risk | Billing (post-aggregation cap) + OSS/Policy (real-time stop) + Finance (credit risk / prepaid gating); Rating sets amount only, no enforcement | `p2` | +| BSS Architecture Manifest | §4.1 Catalog, §4.2 Rating, §4.4 Billing, §2.1.3 identities, §8 data model | `p1` | + +## 14. Assumptions + +- NFR targets are working assumptions (baselines from `PRD-metering-pricing-module-202601120119`) pending the program NFR workshop; capacity planning uses them until committed. +- rating-core is a pure, I/O-free crate within the one `rating` gear deployable (ADR-0002 / T-D-16), not a separate service; the earlier "logical module within the BSS Rating domain" manifest §4.2 note is superseded. +- The windowed `Q` is materialized and owned by the Rating `AggregationWindow` (single writer per `(subscription, meter, dimensionKey, window)`); Rating receives `Q` as a frozen input. +- OSS metering will emit `dimensionKey` values on usage; until then `dimensionKey` is the empty tuple and per-combination meters are the only workaround. +- Catalog/Contracts supply `glCode`/SSP/PO and FX policy pointers as frozen inputs; Rating consumes, never recomputes, supplied evidence. +- Promotions will provide a frozen coupon snapshot contract before production coupon rating; until then §17.2 is the Rating-side stub. + +## 15. Open Questions + +| **Question** | **Owner** | **Target Date** | **Answer** | **Date Answered** | +|--------------|-----------|-----------------|------------|-------------------| +| Numeric SLOs (p95/p99, max RPS per partition) for the pricing hot path | Program NFR workshop | TBD | Working assumption: p95 <= 100 ms per catalog lookup; p95 < 1 s overall rating path; >= 10M events/day/region. Final acceptance at NFR workshop. | — | +| Default anti-drift cap (`maxCumulativeMarkup`) value and clamp-vs-fail behavior across partner→reseller→customer | Program / Finance workshop | TBD | Step 4 is normative — a material multi-link chain MUST fail-closed at publish without a configured cap; only the default cap value and clamp-vs-hard-fail mode remain open. Single-link/non-material overlays MAY warn. | — | +| Non-negative resolved price: clamp-to-zero vs emit-as-credit | Finance | TBD | §6.1 guard is normative; only the residual-handling policy is deferred. | — | +| Follow-on capabilities (percentage, min/cap per period, bilateral, two-dimensional) | Program workshop | TBD | Prioritize after Design lock for current Scope; see §17.4. Dimensional, CAPACITY/reservation, and composite meter are in Scope. | — | +| Promotions PRD field names and coupon snapshot event contract | Promotions + Design | TBD | Align with §17.2 before production coupon rating; Rating-side semantics are normative here. | — | +| Formal confirmation of rating-core deployment model (submodule of Rating vs standalone service) | Architecture / Program leadership | Before Design lock | Normative for Design: submodule of Rating. Executive confirmation pending; standalone requires manifest update. | — | +| Minimal cloud subset for a real S3 / VM / Disks catalog | PM Team | 2026-06-11 | Resolved: Dimensional and CAPACITY/reservation (consumption + capacity flavor) in Scope; **Composite meter is in launch** — the pricing gear delivers the derived-meter primitive (Slice 10, formula-as-data over ≥2 published units) and hands Rating the eval math (SEAMS.md M5, 2026-07-10); VM MAY also be priced via the instance-type dimension. (The prior Follow-on status assumed no upstream primitive; superseded.) | 2026-06-11 | +| Usage dimension-population contract (emission of `dimensionKey` values, field shapes, normalization) | OSS / CyberFabric Core (emission); Rating (declare/freeze) | TBD | BSS side closeable now (declare + freeze; Rating passes through). External dependency / critical path: the OSS metering emission shape. Until OSS emits values, `dimensionKey` stays empty. | — | +| (Finance) Launch without a hard spend cap / real-time spend stop — accepted? Owner of credit risk + prepaid gating | Finance | TBD | Rating owns no enforcement. Finance MUST accept launch without a ceiling, or name the gating owner (Billing post-aggregation cap / OSS-Policy real-time stop). | — | +| (Product + OSS/Policy) Free-tier level: per-meter $0 band vs per-account-per-service allowance; boundary behavior and enforcing domain | Product + OSS/Policy | TBD | Current Scope = per-`(meter, dimensionKey)` $0 band; cross-account allowance is a new aggregate (Follow-on). | — | +| (Product + Finance) Per-resource minimum charge and stance on rapid create/delete churn | Product + Finance | TBD | `minimumCharge` MAY be configured per resource; churn policy undecided. | — | +| (Finance + Legal/Tax) "Discount vs tax" ordering per jurisdiction, and whether a contractual floor claws back coupon discount | Finance + Legal/Tax | TBD | Rating emits discount lineage for Billing/Tax; default proposal = floor compares post-coupon total. | — | +| (Operations / Portal) Owner of real-time consumption visibility + budget/limit alerts | Operations / Portal | TBD | Not a Rating requirement; name the Billing/Portal owner. | — | + +## 16. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Usage dimension contract slips (OSS emission) | `dimensionKey` stays empty; per-combination meters explode catalog cardinality; S3/VM cannot be billed by dimension | Lock the BSS-side dimension contract now; raise OSS emission shape as an upstream Usage Collector requirement (critical path) — §17.3 | +| rating-core deployment reversed to standalone service | Manifest contradiction; integration rework | Treat submodule-of-Rating as normative for Design; require executive confirmation + manifest update before reversal (§15) | +| Uncommitted NFR numbers (p95, throughput) | Blocks engineering capacity planning | Commit working-assumption NFRs at the program workshop before Design lock (§7.1, §15) | +| Missing anti-drift cap on material multi-link chains | Unbounded markup compounding across the channel | Step 4 fail-closed at publish without a cap; Finance-set default; clamp/fail mode decision (§15) | +| `PRD-rating-engine-202604031200` draft/empty | Integration contract undefined | This PRD supplies formula semantics; Rating remains authoritative for the pipeline; resolve contract before Design lock | +| Coupon snapshot contract undefined (no Promotions PRD) | Non-reproducible coupon rating | Treat §17.2 as the Rating-side stub; align field names/events before production coupon rating | + +## 17. Reference Materials + +| **Material** | **Link** | **Comments** | +|--------------|----------|--------------| +| BSS Architecture Manifest | `docs/bss/manifest/vz-arch-manifest-bss-only.md` | §4.1 Catalog, §4.2 Rating, §2.1.3 identities, §4.1 invariants | +| Project glossary | `docs/project-glossary.md` | Canonical terms | +| Trace chain | `AGENTS.md` (repository root) | Manifest → PRD → ADR → Design → Stories | +| Metering & pricing predecessor | `docs/bss/prd/PRD-metering-pricing-module-202601120119/PRD-metering-pricing-module-202601120119.md` | NFR baselines; pricing-hierarchy scope migrated here | +| Usage-based pricing platforms (benchmark) | Metronome, Lago, OpenMeter | Reference for cloud model coverage and scope sequencing (dimensional, composite, capacity/reservation) — §17.3 | + +### 17.1 Rule Evaluation Order (normative appendix, steps 1-9) + +For any evaluation at timestamp `t` (UTC) and context `ctx`: + +1. **Subscription composition**: Resolve active `planId`/`skuId` links and **plan phase** (trial / intro / evergreen or successor phases per Subscriptions SoR) effective at `t`. Phase selects the applicable price schedule within the plan. +2. **Base catalog row**: Select `Price`/`PriceWindow` such that `t in [effectiveFrom, effectiveTo)` on the pricing 8-axis canonical scope key `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` per the non-overlap invariant (manifest §4.1). Apply `priceEligibility` in class order `existing_grandfathered > new_subscriptions_only > all_subscriptions`: `new_subscriptions_only` excludes subscriptions with `activatedAt` before window `effectiveFrom`; `existing_grandfathered` includes only subscriptions activated before cutover, and within it the generation is selected by the `cohort` of the subscription's pinned price id in `pricingSnapshotRef` (never `activatedAt` alone). If no eligible window matches, evaluation MUST fail (no silent fallback). Native multi-currency: when invoice currency equals the row's price currency, skip step 8 FX. +3. **Meter mapping and billing granularity**: Map `UsageRecord` to a charge line keyed by `(meter, dimensionKey)` — the mapping MUST be injective on `(meter, dimensionKey)` per plan revision, or reject as a configuration error (fail-closed). A plan with no declared dimensions uses the empty `dimensionKey`. `billingGranularity` round-up MUST be applied to the aggregated/merged measure of the evaluation unit, never per raw `UsageRecord`. For continuous-duration meters, contiguous usage MUST be merged into a session/window measure first, then rounded up once; for discrete-count / `per_event` meters, the unit is the event; for windowed tier/volume models, round-up applies to the window measure before tier placement. The merge/aggregation is owned by Rating (single-writer per `(subscription, meter, dimensionKey, window)`); Rating prices the normalized aggregate. For `tierAggregationWindow != per_event`, tier/volume math MUST be evaluated over the window-aggregated quantity `Q`. When a §6.11 boundary (mid-cycle activation, plan change, phase conversion) splits an open aggregation window, each sub-window slice prices its own attributed quantity with a band offset equal to the accumulated prior-slice `Q` (tier-counter continuity, pricing `inst-tb-window-continuity`): graduated places marginally from the offset; volume selects the band by window-cumulative `Q`; package counts blocks once over the window by cumulative ceil-diff. +4. **Partner / OrgTier / brand / region overlays**: For each candidate `PriceOverlay`, apply the scope filter (§PriceOverlay scope mapping below), then apply all survivors as a sequential stack in a deterministic total order: ascending `precedence` (lower first); cross-class ties resolve by the pricing class-specificity order `customerGroup > partner > orgTier > brand > region > global` (adopted verbatim), with ascending `priceOverlayId` as the final within-class stable tie-break. This layer stacks (applies all survivors); the class order breaks ties, it does not pick a single winner. Equal `precedence` among lists with overlapping scope within one class MUST be rejected at publish (fail-closed); the class order + `priceOverlayId` tie-break is a runtime safety net. Bounded composition: the cumulative markup/discount across the full partner → reseller → customer overlay chain MUST be bounded by a configured cap (`maxCumulativeMarkup`); exceeding it MUST clamp and record (or fail-closed if hard). A material multi-link chain without a configured cap MUST fail-closed at publish. +5. **Customer / contract overlay**: Apply contract/account-level overrides after step 4, bounded by entitlement and approval rules. Contract terms outrank partner lists (Contract > Partner price overlays > Catalog base). Overrides MUST NOT introduce metering dimensions absent from the published Plan/SKU revision (publish validation rejects fail-closed). +6. **Commitment rules**: Apply drawdown/overage per contract over an ordered list of commitment pools (`commitmentPools[]`, Contracts SoR). Commitment is always evaluated at step 6 (no reordering knob). When `reservationMatch` is present, the reserved/covered portion is determined first and excluded from pool drawdown **and from the on-demand tier counter** (the remainder re-bands from zero; the in-commit pool quantity is not excluded); the remaining quantity draws down `commitmentPools[]` (waterfall); residual beyond all pools is overage / on-demand. In-commit billability follows the pool's frozen `poolType` (`prepaid_drawdown` due-zero vs `committed_rate` in-arrears — §6.2). The frozen pool set, `poolType`, balances @ `balanceVersion`, draw order, rollover policy, and reserved-vs-pool split MUST be carried in `pricingSnapshotRef`. +7. **Coupon overlay (Promotions)**: Apply eligible Coupon adjustments with `settlementCurrency = price` on the post-commitment line amount in price currency. Default stacking: `exclusive_best`. Record applied coupon id(s) and pre-/post-discount amounts. +8. **FX policy (rating-core abstraction)**: If invoice currency != price currency, rating-core MUST apply the FX table per policy (inputs from Finance); no implicit/provider-default FX without a policy record. Two policies: (a) per-window rate-lock (final at event time); (b) invoice-period FX (provisional amount on the hot path; re-rate by delta at period close — close-time `fxTableVersion` authoritative). Then apply coupons with `settlementCurrency = billing` to the billing-currency amount (same `fxTableVersion`). +9. **Emit monetary amounts (rating-core → Billing boundary)**: rating-core MUST emit amounts with precision sufficient for Billing; invoice rounding (per-line vs per-invoice) is applied by Billing, not rating-core. rating-core records the rounding policy id. The resolved per-line amount MUST NOT be negative before period-level phases. + +> **Reserved-capacity component**: when `reservationMatch` is present, a reserved-capacity charge is evaluated at step 6 in one of two flavors: (a) consumption-flavor (matched usage at reserved rate, remainder on-demand); (b) capacity-flavor (`capacityCharge` on allocated `reservedQuantity` regardless of usage). The reserved portion is excluded from `commitmentPools[]`. Flavor and `reservedQuantity` frozen in `pricingSnapshotRef`. + +> **Period-level phase (outside the per-line order)**: floor / cap per period are applied after step 9, over the period aggregate, by Billing (§17.2). Steps 1-9 are per-line and have no slot for period-level min/max. + +#### PriceOverlay scope mapping (used in step 4) + +| **`PriceOverlay.scope`** | **MUST match (evaluation context)** | +|-----------------------|-------------------------------------| +| `global` | Always eligible (subject to plan/SKU applicability) | +| `customerGroup` | Payer's BSS-resolved customer group at `t` (from authenticated caller claims); most-specific class | +| `partner`, `orgTier` | `sellerTenantId` (channel/reseller that sold the subscription) | +| `brand` | Plan/SKU `brandId` at `t` | +| `region` | Usage or price-row `region` key | + +Tenant axes NOT used as `PriceOverlay.scope` filters: `resourceTenantId` (usage tenancy; MUST NOT alone match partner/orgTier rows); `payerTenantId` / `accountId` (contract/account overlays in step 5); `sellerTenantId` (used for `scope(partner|orgTier)`). + +#### Determinism and Rating compatibility (preserved) + +- **Pure function core**: determinism stated over the evaluation unit; for windowed models the window-aggregated `Q` for `(subscription, meter, dimensionKey, window)`. Given frozen inputs, the monetary outcome MUST be identical across replay, recompute, and cross-region batch workers. +- **Windowed `Q` ownership (single-writer)**: materialized and owned by the Rating `AggregationWindow`, single writer per partition key; concurrent re-resolve serializes on the partition key. +- **Non-negative resolved price**: MUST NOT go negative; clamp to zero or emit a structured credit (policy TBD). +- **Usage corrections / negative quantity**: deterministically reverse prior effect (refill pool, decrement `Q`), emit compensating deltas; never drive a line negative. +- **Snapshot carry / idempotency / delta idempotency / separation**: per §6.1. + +#### Multi-currency (preserved) + +- **Price currency**: currency of the `Price.amount` row selected in step 2; per-market list prices are first-class. +- **Presentment currency**: portal display FX, non-authoritative, outside rating-core; MUST be labelled estimates. +- **Billing currency**: invoice currency per payer account/contract; rating-core converts per step 8; per-window rate-lock final at event time; invoice-period FX emits provisional + re-rates by delta at close. +- **Coupons and currency**: price-currency coupons in step 7; billing-currency coupons after step 8. +- **rating-core / Finance boundary**: FX tables and lock policies owned by Finance; rating-core records `fxTableVersion` / locked-rate id. +- **rating-core / Billing boundary**: rating-core MUST NOT apply invoice rounding; Billing rounds in billing currency after conversion. + +### 17.2 Boundary Contracts (coupons, floor/cap, plan-change proration) + +**Coupons (Promotions boundary)** — normative order extends §17.1: Catalog base → Partner/OrgTier/brand/region (`PriceOverlay`) → Customer (contract/account) → Commitment (step 6) → Coupon (step 7) → FX (step 8) → Emit (step 9). Coupons apply after customer overlay and after commitment math; default before FX (price currency), exception after FX for `settlementCurrency = billing`. Coupon + partner discount both apply (partner in step 4, coupon in step 7). Coupon + graduated tier: default on the total line amount after tier math; `applyPerTierBand = true` applies per marginal band. Stacking: `exclusive_best` (default — largest customer benefit, others excluded), `ordered_stack` (campaign-linked `stackSequence` only), incompatible pairs fail-closed at redemption bind. Consumption contract (Rating ← Promotions): a frozen coupon snapshot with at minimum `couponId`, `adjustmentType` (percent \| fixed_amount), `value`, `settlementCurrency` (price \| billing), `applyPerTierBand`, `applyScope` (`usage` \| `recurring` \| `line_total`, default `line_total`), `stackSequence` (required under `ordered_stack`), validity, applicability filters, redemption eligibility. Missing `applyScope` (or `stackSequence` under `ordered_stack`) MUST fail-closed. + +**Period-level floor and cap** — period-level phases over the aggregated total; applied after step 9 by Billing. Attach to the usage component by default, or recurring+usage if plan-level (frozen in `pricingSnapshotRef`). Set in price currency, converted with the same FX policy/`fxTableVersion` as step 8 (billing-currency floor/cap compared after conversion; currency explicit, no implicit default). Rating sets the amount/currency/scope and emits `PeriodFloorCapObligation`; Billing executes `max(total, floor)` / `min(total, cap)`. The non-negative guard applies before floor/cap; a floor MUST NOT mask a negative line. Whether a contractual minimum-spend floor claws back coupon discount is unresolved (default proposal: floor compares post-coupon total) — §15. + +**Plan-change proration** — Subscriptions owns WHEN and the up/down asymmetry policy (cross-PRD); Rating owns the evaluation semantics and consumes `(changeEffectiveAt, changeMode)`. On a plan change at `changeEffectiveAt`, rate planA over `[periodStart, changeEffectiveAt)` and planB over `[changeEffectiveAt, periodEnd)` (half-open, UTC), each against its own revision and snapshot, at full precision (Billing aggregates). Recurring component prorated on the configured `prorationBasis`. Tier `Q` carry-vs-reset and commitment-pool carry-vs-reset across the boundary frozen in the snapshot (default reset unless marked carry). Prorated corrections to an already-rated portion emitted as deltas via the Adjustment path. Rating consumes `changeMode` to pick the split point; the policy that sets the mode is Subscriptions. + +### 17.3 Cloud Catalog Readiness and Phasing + +The cloud-defining models for a genuine S3 + VM + Disks catalog that are in Scope: **Dimensional pricing** and **CAPACITY / reservation pricing** (consumption- and capacity-flavor), plus the **usage dimension-population contract**. **Composite meter is in launch** — the pricing gear provides the derived-meter primitive (Slice 10) and Rating evaluates the formula-as-data; VM MAY also be priced via the instance-type dimension. The engine seams (`dimensionKey`, `reservationMatch` + `capacityCharge`, `commitmentPools[]`, `maxCumulativeMarkup`) admit further models additively — no change to the published snapshot/Rating contract. + +| **Item** | **Scope** | **Unlocks** | **Hard precondition (owner)** | +|----------|-----------|-------------|-------------------------------| +| Dimensional pricing | Scope | S3 by storage-class / region / operation; VM by instance type | OSS metering emission of dimension values (external; BSS declare/freeze + Rating pass-through owned here) — critical path | +| CAPACITY / reservation pricing | Scope | Provisioned Disks / IOPS, RI-style commitments | `reservationMatch` entitlement source (OSS / Contracts) | +| Composite meter | **In launch** | VM = vCPU + RAM as one priced line | Derived-meter primitive delivered by pricing Slice 10 | + +**Sequencing**: (1) lock the BSS-side dimension contract (Rating declares + freezes; Rating passes `dimensionKey` through) and raise the OSS metering emission shape as an upstream requirement to the Usage Collector PRD — the external emission is the critical path and blocks Dimensional pricing. (2) Dimensional → (3) CAPACITY/reservation. Composite meter is in launch — the pricing gear delivers the derived-meter primitive (Slice 10), so no upstream wait applies. **Risk if the dimension contract slips**: `dimensionKey` stays the empty tuple and dimension combinations can only be expressed by minting a separate meter per combination — exploding catalog cardinality. + +### 17.4 Future Scope + +**Tariff semantics — formulas and computation** + +| **Capability** | **Priority** | **Status** | **Notes** | +|----------------|--------------|------------|-----------| +| Percentage pricing (% of base amount) | `p2` | Follow-on | Marketplace/payments; new model row in Design | +| Bounded override composition (anti-drift caps) | `p2` | Follow-on | Contract defined now (`maxCumulativeMarkup` on the overlay chain, step 4); rich policy object phased | +| Minimum fee (floor) per period | `p2` | Follow-on | Boundary/contract defined now (§17.2); Rating sets amount, Billing executes; impl phased | +| Cap (ceiling) per period | `p2` | Follow-on | Boundary/contract defined now (§17.2); bill-shock protection executed by Billing post-aggregation; impl phased | +| Two-dimensional pricing (seats x usage) | `p2` | Follow-on | Multiple meters + hybrid model; Subscriptions seat count input | +| Meter aggregation functions beyond `sum` (peak / last / unique) | `p2` | Follow-on | Launch composite/derived-meter derivation is window-`sum` only (pricing D Q2); other functions phased | +| Non-negative price after stacked discounts | `p3` | Deferred | Guard is normative (§6.1); only the clamp-vs-credit policy is deferred to Finance workshop | + +**Plan structure and effective dating** + +| **Capability** | **Priority** | **Status** | **Notes** | +|----------------|--------------|------------|-----------| +| Extended multi-SLA tier packs (beyond manifest `PlanTier`) | `p2` | Follow-on | Current Scope uses `PlanTier` only; full tier bundles with per-tier SLA packs | +| Plan change policy (immediate vs end-of-term, asymmetric up/down) | `p2` | Cross-PRD | WHEN/asymmetry owned by Subscriptions; Rating proration semantics defined now (§17.2, AC 17) | + +**Commitments and reservations** + +| **Capability** | **Priority** | **Status** | **Notes** | +|----------------|--------------|------------|-----------| +| Commitment rollover (burn vs carry) | `p2` | Follow-on | Per-pool policy on `commitmentPools[]` (step 6); additive | +| Multi-pool waterfall drawdown | `p2` | Follow-on | Enterprise contracts; additive over the ordered `commitmentPools[]` waterfall | +| Free tier as structural concept | `p2` | Follow-on | Current Scope expresses free only as a per-`(meter, dimensionKey)` $0 band; cross-account allowance is a new aggregate | +| Multi-year ramp, convertible RI, sustained-use auto-discount | `p3` | Deferred | Enterprise/cloud advanced | + +**Cloud-specific models** + +| **Capability** | **Priority** | **Status** | **Notes** | +|----------------|--------------|------------|-----------| +| Bilateral pricing (source x destination) | `p2` | Follow-on | `(source, destination)` as `dimensionKey`; consistent with the injective `(meter, dimensionKey)` rule | +| BYOL / license-attached discount | `p2` | Cross-PRD | Entitlement in OSS/Contracts; Rating consumes license flag in ctx | +| Retroactive volume tier on monthly accumulation | `p2` | Follow-on | Batch re-rate at period close; open-window late-arrival semantics defined now (AC 10) | +| Burstable credits, storage tier transitions, spot pricing | `p3` | Deferred | Cloud provider advanced | + +--- + +*Child artifacts: ADR(s) for precedence conflicts and snapshot versioning strategy; DESIGN for Rating / rating-core ↔ Rating / Finance FX integration contracts and evaluation traces.* + diff --git a/gears/bss/rating/docs/SEAMS.md b/gears/bss/rating/docs/SEAMS.md new file mode 100644 index 000000000..ea78af106 --- /dev/null +++ b/gears/bss/rating/docs/SEAMS.md @@ -0,0 +1,240 @@ +# Rating ⇄ Pricing — Complementarity Seam Map + +> Cross-gear seam analysis produced before the Tariffs design, against the mature +> **pricing** design set (`gears/bss/pricing/docs/`). The pricing gear is the ratified +> System of Record for the canonical scope key, PriceWindow machinery, publish-time +> governance, and the frozen consumer contract (`design/06`). The Tariffs PRD is the +> incoming baseline (`gears/bss/rating/docs/PRD.md`, vendored from upstream PR #4143). +> +> **Verdict legend:** `T-adopts` = Tariffs adopts a pricing-side fact (no pricing change); +> `P-extends` = pricing must expose/extend something; `Joint` = a shared contract needs a +> co-decision; `Product` = a launch-scope / commercial call for the product owner. +> +> Severity: `CRIT` (breaks resolution correctness), `HIGH`, `MED`, `LOW`. +> Line refs: `T:` = tariffs PRD.md; pricing refs carry their path. +> +> **2026-07-11 (ADR-0002 / T-D-16)**: the Tariffs gear was consolidated into the **rating** gear +> as its evaluation core (`rating-core`). This map is the **historical record** of the 2026-07-10 +> analysis: "Tariffs" below reads as the rating gear's evaluation core, and "Rating" as the rating +> pipeline (same gear since consolidation). All seam resolutions remain binding verbatim. + +--- + +## A. Canonical scope key & resolution order + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **K1** | CRIT | T-adopts | **Step-2 selection key is 4-axis, owner's is 8-axis.** Tariffs selects on `(planId, currency, region, phase)[+priceOverlay]` and asserts "at most one window MUST match" (T:125, T:447). Pricing's canonical key is 8 additive axes `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` (`design/01:321`), and it *deliberately* publishes many concurrently-active rows on the shorter tuple (hybrid chargeKinds; grandfathered generations + successor). Tariffs' "one match" fails-closed on exactly the legitimate catalogs pricing engineered. → Tariffs adopts the 8-column non-overlap/selection key verbatim (already a cross-team obligation: `ADR/0002:75`, `ADR/0001:102`). | +| **K2** | CRIT | T-adopts | **cohort / multi-generation grandfathering absent.** Tariffs models a *flat* `existing_grandfathered` (activated-before-cutover, T:521). Pricing's ADR-0002/D-02: N generations coexist, each an active window; Tariffs must select the row whose `cohort` = the cohort of the subscription's **pinned price id** in `pricingSnapshotRef` (`ADR/0002:69`, `design/07:232`). No new store — the pin already exists. → Tariffs adds class-then-cohort within-class selection. | +| **K3** | HIGH | T-adopts | **`chargeKind` is not a selection axis.** Recurring and usage rows differ *only* by `chargeKind`; without it in the key, step-2 selection is non-unique (T:413 knows hybrid emits two lines but never keys on it). Pricing: `chargeKind ∈ {recurring, usage, one_time, one_time_setup}` is column-6 (`design/01:326`). → include `chargeKind` in the step-2 key; each emitted line resolves its own row. | +| **K4** | MED | T-adopts | **Eligibility class order unstated.** Pricing: `existing_grandfathered > new_subscriptions_only > all_subscriptions`, class ordering only (`design/07:111/231`). Tariffs implies precedence but never states the total order. → state it as the step-2 tie-break (pairs with K1/K2). | +| **K5** | MED | T-adopts | **`phase` axis typing drift.** Tariffs refers to phase by kind-name (trial/intro/evergreen, T:531). Pricing D-19: `phase` axis is always a `phase_id` (uuid); non-phased/one-time rows ride an implicit **terminal `phase_id`** (`design/01:324`). A name-based match won't join the uuid axis. → specify `phase` as `phase_id`; kind names are display only. | + +## B. Overlays / PriceOverlay precedence + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **O1** | HIGH | T-adopts | **Cross-class tie-break uses `priceOverlayId`; owner mandates class-specificity order.** Tariffs: ascending `precedence` then ascending `priceOverlayId` (T:479). Pricing: `precedence` is unique only *within* a class; cross-class ties resolve by `customerGroup > partner > orgTier > brand > region > global`, which "Tariffs MUST adopt verbatim" (`design/09:184`). → replace the `priceOverlayId` cross-class tie-break with the class order. | +| **O2** | HIGH | T-adopts | **`customerGroup` scope class missing.** Tariffs scope enum = `{partner, orgTier, brand, region, global}` (T:126). Pricing adds `customerGroup` as a first-class, server-resolved segment overlay and the *most-specific* class (`design/09:181/184`). → add `customerGroup` to the step-4 scope mapping (matches payer's BSS-resolved group at `t`). | +| **O3** | HIGH | **RESOLVED → stack** | **Stack-all vs single-winner at step 4.** Tariffs *stacks* all scope-matching survivors sequentially (T:479); pricing frames overlay resolution as most-specific-*winner*. **Decision (2026-07-10):** step 4 **stacks** all survivors (partner + brand + region overlays are legitimately cumulative). Pricing's "most-specific-wins" governs (a) `priceEligibility` *class* selection and (b) *ties at equal precedence* — NOT overlay exclusivity. The class-specificity order (O1) is the tie-break within the stack, not a winner-take-all filter. Pricing to confirm its wording reads as tie-break, not exclusivity (expected already true). | + +## C. PriceWindow ownership & events + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **W0** | — | ALIGNED | **Marquee ownership (D-03/ADR-0003): no conflict.** Tariffs treats PriceWindows as externally-owned read-only inputs — Catalog/Price-Book is "SoR for … PriceWindow; emits schedule-change events" and Tariffs "MUST consume PriceWindow* events" (T:212/261). Pricing (Slice 7) owns the store, state machine, activation job, and event production (`ADR/0003:72`). The pricing gear *is* the Catalog/Price-Book SoR Tariffs references. | +| **W1** | HIGH | T-adopts | **`PriceWindowCancelled` not consumed.** Pricing emits four events incl. `Cancelled` (retirement/cutover unwind, operator DELETE of not-yet-active windows; `design/07:154`). Tariffs' consume list omits it (T:261). A pre-cached scheduled window that pricing later cancels would never be retracted → Tariffs resolves against a voided window. → add `PriceWindowCancelled` to the consumed set. | +| **W2** | MED | **RESOLVED → Tariffs replays snapshot** | **Open-period re-resolution needs historical windows.** Tariffs re-resolves late-arriving usage for `periodState=open` and mid-period splits (T:663/697), which read window(s) effective at a *past* `t`. Pricing's read-model contract exposes only *active-window-at-`t`* (`PRD:214`); history is in-table SoR (R-12). **Decision (2026-07-10):** Tariffs re-resolves **strictly from the pinned snapshot** (the snapshot pinned at first rating of that window), never a live catalog read — for both posted and open-period late-arrival. No pricing read-model change; pricing only guarantees snapshot retention for the open window. (Rejected: pricing exposing a historical-window query surface — more cost, weaker determinism.) | + +## D. Snapshot composition + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **S1** | MED | **RESOLVED → canonical list** | **`pricingSnapshotRef` = one ref, three writers; segments not textually reconciled.** Minting authority *is* aligned — pricing names Tariffs the composition SoR (`design/01:214`). **Decision (2026-07-10):** canonical field list, per-segment writer: `catalogVersion` (pricing, pending→committed on `CatalogVersionPublished`) · `resolved price ids` incl. `cohort` (pricing) · `eval-policy version` (pricing) · `(currency, region)` binding (Subscriptions @ activation) · resolved overlay/`priceOverlay` ids (Tariffs @ eval) · applied coupon ids + stacking policy (Tariffs @ eval) · FX-lock id (Tariffs @ eval). Tariffs = composition SoR; both docs record the list identically. "resolved price ids" carry `cohort` (feeds K2). **2026-07-11 (T-D-09):** the canonical list gains the eighth Tariffs-written segment `commitmentReservation` (reservation match; pool set incl. `poolType`, balances @ `balanceVersion`, draw order, rollover; reserved-vs-pool split). | + +## E. Pricing models & metering + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **M1** | HIGH | T-adopts | **model-kind enum mismatch.** Pricing enum: `{flat, per_unit, graduated, volume, package}` (`PRD:154`). Tariffs §6.2: `{flat, tiered(graduated), volumeA, volumeB, hybrid, committed}`. `hybrid`/`committed` are plan-composition/commercial constructs, not model-kinds (pricing already treats them so). → adopt the pricing §17.2 kind→formula mapping as shared SoR; reclassify hybrid/committed. | +| **M2** | HIGH | **T-adopts (launch-blocking)** | **`per_unit` (per-seat) uncovered.** Pricing p1 launch (`quantitySource ∈ {subscription_seat_count, manual}`, `PRD:363/467`); FixtureGate blocks publish without a joint Tariffs fixture. Tariffs has no per-seat FR (classifies it Follow-on, T:1273). → Tariffs adds a `per_unit` formula FR (unit price × seat count from Subscriptions) + joint fixture, or per-seat plans cannot publish. | +| **M3** | MED | **RESOLVED → T-adopts** | **`package` (block) modelKind — pricing has it, Tariffs has zero (0 hits).** Pricing: `ceil(used/packageSize) × packagePrice`, Tariffs computes the round-up (`PRD:156/509`); p2, own joint fixture required. `package` ≠ Variant B (different math). **Decision (2026-07-10):** Tariffs adds a package-pricing FR (`ceil(used/packageSize) × packagePrice`) — parity with pricing p2 launch. | +| **M4** | MED | T-adopts (delete) | **Volume Variant B (block fee) is an orphan.** Tariffs authors it (T:399); pricing explicitly refuses — "`volume` maps to Variant A only … Variant B dropped and not authorable" (`design/03:99`, D Q3). No catalog authoring home. → delete Variant B from Tariffs §6.2. | +| **M5** | MED | **RESOLVED → T-adopts** | **Composite/derived meter status drift.** Tariffs: Follow-on, "blocked on a derived-meter primitive that must land first" (T:1259). Pricing *delivers* it at launch (Slice 10 formula-as-data over ≥2 units) and hands Tariffs the eval math (`design/10:47`, `PRD:412`). **Decision (2026-07-10):** composite **is in launch**; Tariffs drops the Follow-on marker and adds a composite-formula eval FR consuming the pricing-declared derived meter. | +| **M6** | MED | Joint | **Dimensional pricing status/ownership.** Tariffs §6.7 p1 launch-critical; pricing glossary marks dimensional **Future** yet persists `dimension_key` structurally (`design/03:267`). Both effectively launch empty-tuple (OSS emission gates values). Declaration ownership unassigned. → agree wording: declaration+freeze in-scope now (catalog persists `dimension_key`, Tariffs freezes in snapshot); value-pricing OSS-gated/Future. | +| **M7** | MED | **RESOLVED → superset key** | **Tier-counter `Q` key differs.** Tariffs single-writer key `(meter, dimensionKey, window)` (T:457); pricing `Q` is per `(subscription, meter, window)`, survives supersession (`design/03:193`). **Decision (2026-07-10):** canonical key = `(subscription, meter, dimensionKey, window)` — the superset satisfying both (per-subscription reset scope + per-dimension counters). Recorded identically on both sides; Rating remains single-writer per this key. | +| **M8** | LOW | Naming | **"prepaid" collision.** Tariffs "prepaid pool" = `commitmentPools[]` (Contracts SoR, waterfall). Pricing "prepaid credit grant" = plan-attached wallet (`grantAmount`/`expiryPolicy`), balance owned by Billing/Rating, GA-gated. Different constructs, same word. → name distinctly; note in Tariffs §6.6 the wallet grant is a separate Billing-executed drawdown, not `commitmentPools[]`. | +| **M9** | LOW | T-adopts | **Reserved-rate sourcing split unstated.** Pricing: self-service reserved rate from the catalog snapshot; negotiated RI rates from Contracts (`PRD:935`, `design/10:349`). Tariffs prices "the reserved rate" without the source split. → Tariffs §6.6 states the two-source rule (snapshot vs Contracts overlay/step 5). | +| **M10** | LOW | T-adopts | **`aggregationFunction = sum`-only launch limit stated only by pricing** (`PRD:158`, D Q2; peak/last/unique Future). Tariffs §6.5 silent. → add the sum-only note. **RE-SCOPED 2026-07-16 (product call — pricing D-44 / rating T-D-17)**: the launch product set bills on levels (cloudlet peak-per-hour, storage GB-month) → `aggregationFunction ∈ {sum, peak, time_weighted}` + `aggregationGranularity {hour, day}` in launch; non-`sum` = granule fold over gauge samples, `Q` = Σ granule folds (**additive** — M7/T-D-12/corrections untouched); frozen in the snapshot; joint fixture required; `last`/`unique` stay Future; no composite co-occurrence. Recorded both sides: pricing PRD §1.4/§15(F-40)/§17.8 + design/03 Q2; rating PRD `fr-level-aggregation` + design/03/12/13. | +| **M11** | LOW | T-adopts | **D-17 open-top & D-18 usage-only invariants relied on but unrecorded.** Pricing forbids closed-top bands (`TIER_TOP_CLOSED`, deletes the "fail-closed-above-max" branch, D-17) and restricts graduated/volume/package to `chargeKind=usage` (D-18). Tariffs is consistent (no above-max branch, tier machinery presupposes metered `Q`) but never states the guarantees. → add a one-line "catalog guarantees" note. | + +## F. Proration, FX, anchors + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **P1** | HIGH | **T-adopts (live CI gate)** | **`prorationBasis` enum missing `none`.** Pricing canonical `{calendar_days_actual, calendar_days_30, by_second, whole_unit, none}`, adopted verbatim by Tariffs, guarded by CI gate `pricing.contracts.enum_drift` (Critical) (`design/06:100/297`). Tariffs lists only 4 (T:114). → add `none` verbatim (pricing rejects `creditOnDowngrade=true` + `none` at publish, so Tariffs never prorates a `none` row, but must recognize the value for the conformance fixture). | +| **P2** | MED | T-adopts | **`billingAnchorPolicy` + D-20 no-drift clamp not referenced.** Pricing owns `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}` with the 31→28→31 clamp, frozen in the snapshot (`design/06:101`, D-20). Tariffs uses a generic "subscription billing anchor" (T:255). → Tariffs consumes `billingAnchorPolicy` (frozen) as the authority for `invoice_period` boundaries and plan-change split points. | +| **F1** | HIGH | T-adopts | **D-15 phase-invariant usage fallback absent, collides with strict no-gap.** Tariffs: key includes phase + fail on gap (T:141/447). Pricing D-15: usage rows are phase-invariant by default, phase-specific wins — "adopted verbatim by Tariffs" (`DECISIONS:204`). Literal Tariffs raises a spurious eval-fail for a phase covered only by a phase-invariant usage row. → Tariffs encodes phase-fallback for usage rows so the no-gap rule applies to the *resolved* set. | + +## G. Bundles, governance, contracts, ASC 606 + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **G1** | HIGH | **RESOLVED → single engine** | **Double governance.** Both gears claim the two-person publish workflow (Tariffs `fr-publish-approval-governance`, T:766; pricing Slice 5 owns `MaterialityEvaluator` + `ApprovalWorkflow` end-to-end, `design/05:46/215`). Same artifact (price-overlay/overlay publish). **Decision (2026-07-10):** single catalog-publish approval engine = **pricing Slice 5**; Tariffs contributes its four publish-time checks as **registered fail-closed validators** in the pricing pipeline, NOT a second workflow. Contract-level overrides (step 5) stay with Contracts. **Ledger stays separate** — see note below. | +| **B1** | MED | T-adopts | **Bundle summing assigned to Tariffs, but Tariffs is silent on bundles/rev-share.** Pricing: for `sum_of_parts` "the summing is Tariffs'" (`design/08:146`); rev-share normalized to `effective_share_bp` at publish, absorber default platform, "downstream reads only effective shares" (D-07). Tariffs never mentions bundle/rev-share. Clean ownership split (no overlap), but Tariffs must acknowledge the eval-time summing + effective-share pass-through. → add `sum_of_parts` summing + rev-share pass-through to Tariffs scope + §9.2 note. | +| **C1** | MED | T-adopts | **No Catalog/Pricing read-model consumer contract in Tariffs §9.2.** Tariffs §9.2 lists only Rating/Finance/Promotions/Billing (T:818). Pricing `design/06` is a full frozen consumer contract Tariffs must adopt (prorationBasis verbatim, rating-compat `{skuId,planId,priceId}`, grant set, plan-change contract). → add a §9.2 "Catalog/Pricing read-model input contract" + a Subscriptions `(changeEffectiveAt, changeMode)` input contract. | +| **ASC** | LOW | P-extends (deferred) | **SSP/PO source gap.** Tariffs requires `performanceObligationRef` + `sspSnapshotPointer` (nullable, T:754). Pricing supplies `glCode` now but defers SSP to Future (`PRD:2331`). Fields resolve **null at MVP** — no contradiction. → acknowledge null@MVP on both sides; `glCode` flows now. | + +## H. Naming + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **N1** | LOW | Naming | **Gear-name drift.** Tariffs says "Catalog / Price Book" / "Product Catalog"; pricing gear is "Pricing / Product Catalog" and names the consumer `cpt-cf-bss-pricing-actor-rating`. → pin one canonical name for the pricing/catalog gear across both PRDs. | + +--- + +## I. Registry (Product & SKU) — prospective seams (2026-07-11, PR #4177) + +> The **Product & SKU Management** PRD carves the catalog **registry** out of manifest §4.1: +> Product / SKU / Category / Attribute, lifecycle, approvals, and the registry-wide +> `CatalogVersion`. The rating gear becomes a **second-order consumer** (registry → plan-price → +> rating); the resource half of "resource @ price" is the SKU's **metering-unit declaration**. +> **2026-07-16 — vendored**: the PRD now lives on this branch at +> `gears/bss/products/docs/PRD.md` (from `constructorfabric/gears-rust` PR #4177 @ `6d3aab4`; +> upstream-detach decision — this branch is canonical, no PR engagement owed). The RG2 fix is +> **applied in the vendored copy**; RG1 stays open; RG3 is recorded as localization debt in the +> vendored PRD's provenance note. + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **RG1** | HIGH | Joint | **Freeze protocols must compose.** The registry gates posted/contractual resolution on `freezeComplete` (participants: plan-price, Contracts, Billing — **rating absent**); pricing gates the rating pin on `CatalogVersionPublished` + warm-completion (pin lag ≤ 5s); rating replays posted periods strictly from pinned snapshots (W2). One composed contract is needed: is pricing's warm marker the rating-facing freeze ack, or must rating join the participant set? A registry-side reject-until-freeze must not stall the rating pin path. | +| **RG2** | HIGH | Joint | **Single-unit-per-SKU vs dimensional & composite pricing.** Registry: a usage SKU declares **exactly one** metering unit; multi-dimension usage = **separate SKUs** composed at plan/bundle level. Rating/pricing: dimensional pricing is one meter + `dimensionKey` tuple **lines** (rating PRD §6.7), and composite meters are formula-as-data over **≥ 2 input units** (pricing design/10). Where composite input units and declared dimension sets live in the registry model must be pinned jointly — "separate SKUs" and "dimension tuples on one meter" are different shapes. **Proposed pin (2026-07-16, BSS side, for PR #4177 FR `…fr-metering-unit-declaration`):** the root defect is a **unit ≠ dimension conflation** ("exactly one unit *(single-dimension)*"). (1) Keep the invariant verbatim: one usage SKU = one metering unit (counted identity, immutable). (2) Drop "(single-dimension)" and the separate-SKUs mandate: the **declared dimension set** over a meter is a **plan-price concern** — the registry itself delegates "plan-level meter binding is plan-price" (PR PRD:126), pricing already persists `dimension_key` on the plan-price revision (rating PRD §6.7), rating freezes it in the snapshot; no new registry field needed. (3) Separate SKUs demoted rule→option (legitimate only where variants differ commercially: accounting codes/lifecycle); per-combination minting is the rating-PRD §17.3 "exploding cardinality" workaround and must not be the permanent shape (S3 = 6×30×5 = 900 SKUs vs 1 SKU + 3 keys). (4) Composite: the SKU declares the composite's **output** unit as its one unit; input units are referenced by the plan-price formula against the recognized-unit set and **need no SKUs**; "composed at plan/bundle level" must not conflate bundle composition with meter formulas. **APPLIED 2026-07-16** in the vendored copy (`gears/bss/products/docs/PRD.md`, `fr-metering-unit-declaration` reworded; provenance note records the local change) — RG2 **closed**. | +| **RG3** | MED | Naming | **Pre-consolidation naming.** The draft references `PRD-tariffs-pricing-logic` and `PRD-rating-engine` as separate modules and mints `cpt-cf-bss-products-actor-tariffs`. Post ADR-0002 there is one **rating** gear (evaluation core + pipeline; the rating-engine PRD is absorbed), and pricing's consumer actor is already merged into the rating actor. **RECONCILED 2026-07-16** in the vendored copy (first substantive edit, per its provenance note): `…-actor-tariffs` merged into `…-actor-rating`; §2.1 delegations + §17 reference table localized to `gears/bss/rating/docs/PRD.md`; `refs:` front-matter kept verbatim as provenance — RG3 **closed**. | + +Aligned (no action needed): unit-identity **immutability** + deprecate-then-remove (GB ≠ GiB — a +correction is a new unit) matches the frozen-snapshot / pinned-replay doctrine (W2, T-D-04); the +`{skuId, planId, priceId}` rating triple keys off the registry's immutable `skuId`; the +grandfathering invariant (snapshots never mutated) matches posted-period protection. + +--- + +## J. Usage Collector (OSS metering) — ingestion seams (2026-07-16) + +> Pipeline slice [`design/12`](./design/12-usage-ingestion-normalization.md) was designed against +> an abstract "OSS metering" upstream that emits a **durable at-least-once usage stream**. The +> platform's actual metering SoR is the **built v1 Usage Collector** +> (`gears/system/usage-collector/` — implemented, released): deliberately **synchronous +> request/response on every public surface**, with a polled Query SPI that is *eventually +> consistent with no upper bound* at the gear floor, and events / watermarks / backfill +> **explicitly deferred** to a later phase (UC-DESIGN:1171, UC-DESIGN deferred-items table, +> UC-PRD:189). Its domain is product-neutral — `UsageType` (gts_id) / tenant / `resource_ref` / +> `subject_ref` / closed-shape `metadata` — with **no subscription, meter, plan, price, or +> dimension concepts** (business logic is an explicit non-goal, UC-PRD:182). The seams below +> bridge the slice-12 design intent to the built gear; **UC1 gates slice-12 implementation**. +> Line refs: `UC-PRD:` / `UC-DESIGN:` = `gears/system/usage-collector/docs/{PRD,DESIGN}.md`. +> Verdict `UC-extends` = the usage-collector gear must expose/extend something (phase-2 scope). + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **UC1** | CRIT | **UC-extends (metering phase 2)** | **Transport model: designed-for stream vs built pull.** Slice 12's `IntakeConsumer` presumes a durable, at-least-once, per-partition-ordered transport with offsets + source replay (design/12 §3.3/§3.8/§4.3). Collector v1 has **no emission surface**: event architecture "Not applicable in v1"; near-real-time consumers poll a Query SPI that is eventually consistent **with no upper bound**, read-after-write only via the ingestion ack (UC-DESIGN:1171, §3.10). A poll bridge is not a substitute: the raw query has **no accepted-order cursor** (event-time filters miss late-accepted records behind the cursor), no freshness bound, per-plugin consistency ceilings, and deactivations are invisible to it (UC5) — a naive poller silently misses records and retractions → wrong charges. → collector grows a **transactional-outbox emission surface** (`UsageRecordAccepted` — covers usage *and* compensation entries — and `UsageRecordDeactivated` incl. the depth-1 cascade list), ordered per `tenant_id` partition, at-least-once, idempotency-tuple-keyed. The hook is already reserved as **additive within REST v1 / SDK v1** (ADR-0006, UC-DESIGN:1171), and the collector's deferred-items table names exactly this trigger ("re-evaluate when downstream consumers require coordinated historical re-emission"). | +| **UC2** | HIGH | Joint | **Completeness / watermark signal for period close.** Rating stays *correct* without one — open-period late arrival re-resolves from the pinned snapshot, posted-period is delta-only (design/08) — but with no per-`(tenant, UsageType)` ingestion watermark every period close is maximally provisional and the correction-cascade / `Adjustment` volume grows with lateness. Collector v1 explicitly defers watermark/reconciliation metadata (UC-PRD:189; UC-DESIGN:1162). → phase 2 adds an accepted high-watermark per `(tenant, UsageType)` (API + periodic watermark event); rating consumes it as a **soft** close gate (grace window before the period tick finalizes usage lines) — never a correctness dependency. | +| **UC3** | HIGH | Joint | **Attribution join: `(tenant, UsageType, resource_ref, subject_ref)` → `(subscription, meter, dimensionKey)`.** The collector deliberately knows none of the right-hand side. Slice 12's `Normalizer` presumes a frozen subscription/meter linkage (design/12 §4.1). Three pins needed: **(a) meter binding** — the catalog-declared meter carries a `usageTypeRef` (collector `gts_id`) as the authoritative binding, validated at publish (registry/pricing side; adjacent to RG2's metering-unit declaration); **(b) subscription resolution** — `resource_ref` → subscription via the Subscriptions entitlement/resource linkage (S1/SUB-R1 frozen context); an ambiguous or unresolvable `resource_ref` quarantines, never guesses (design/12 §4.5); **(c) dimension carrier** — `dimensionKey` values ride the UsageType's declared closed-shape, string-only `metadata_fields`; the meter's declared dimension set must equal the declared metadata keys (publish-time cross-validation — the G1 registered-validator pattern). (c) closes the §17.3 emission-shape half of **M6**. | +| **UC4** | MED | Joint | **Dedup-key derivation across the boundary.** Slice-12 `usageKey = (sourceSystem, meterId, sourceEventId)` (design/12 §4.3); collector identity is `(tenant_id, gts_id, idempotency_key)` — unique per tenant **per UsageType**, unbounded window, exact-equality retry absorbed / divergent reuse rejected as conflict (UC-PRD fr-idempotency). Map: `sourceSystem = usage-collector`; `meterId` ← the UC3(a) binding; `sourceEventId` ← the full `(tenant_id, gts_id, idempotency_key)` tuple — the *contractual* identity (plugin record ids are backend-scoped). Key reuse across UsageTypes is legitimate collector-side, so `sourceEventId` MUST embed the `gts_id` scope. Both dedup windows unbounded — aligned. | +| **UC5** | MED | Joint | **Corrections mapping + visibility.** Semantics map cleanly: a collector **compensation** (counter-only, strictly-negative, references the original active entry) → a correcting `UsageRecord` with `corrects` lineage (design/12 §4.4); an **event deactivation** (whole-row one-way `active→inactive`, depth-1 cascade flipping its active compensations) → a full-reversal correction of the record's prior contribution to `Q`. But rating must *observe* both: compensation rides the collector's ingestion path (UC1's `UsageRecordAccepted` covers it); **deactivation is an operator REST write with no ingestion event** — the UC1 surface MUST emit `UsageRecordDeactivated` (with the cascaded compensation flips), else retractions are silently missed. Rating side unchanged: the mapped correction re-materializes `Q` (design/13 §4.4) and re-resolves under `periodState` rules (design/08 §4.3). | +| **UC6** | MED | Joint | **Temporal shape: point-stamped values vs intervals.** The collector v1 record is `(value, created_at)` under `counter`/`gauge` kinds — **no interval kind**; rating design/12 §3.1 expects "quantity **or start/stop interval**" (the `SessionMerger` overlap-union machinery presupposes `[start, stop)` geometry). Bridge today: duration consumption is emitted as **chunked counter deltas** (chunk cadence ≤ the finest attribution boundary — splits land on day granularity, practical guidance: hourly), which reduces slice-12 session merge to summation; `gauge` **is rateable since D-44/T-D-17** (M10 re-scoped 2026-07-16): level meters declare `aggregationFunction ∈ {peak, time_weighted}` and rating folds samples per granule — sources emit raw levels, never pre-folded values. The event never carries a billing period — windowing is rating's (`WindowResolver` from event time under the frozen anchor), and sources MUST NOT round (`billingGranularity` round-up is the core's, after merge). → a native **interval/duration kind** is a phase-2+ collector increment candidate (additive; the feed entry shape of `fr-usage-event-feed` carries it untouched); until then the chunked-delta contract + granularity rule is the pinned bridge. | + +Aligned (no action needed): mandatory client idempotency keys + **unbounded** dedup window + +fail-closed ingestion + immutable rows + append-only corrections + "no business logic in the +collector" all match slice 12's mediation doctrine (normalize-once/freeze-forever, authoritative +dedup, quarantine-never-guess); NFR envelopes compose (collector sustains ≥ 10k rec/s ≈ 864M/day +platform-wide vs rating's ≥ 10M ev/day/region intake). + +--- + +## Ownership matrix (contested/adjacent responsibilities) + +| Responsibility | Owner | Seam | +|---|---|---| +| Canonical scope key (8 axes) | **Pricing** (SoR); Tariffs adopts | K1–K5 | +| cohort/generation selection | Split: pricing publishes generations, Tariffs selects by pinned cohort | K2 | +| PriceOverlay authoring | **Pricing** | O1–O3 | +| Overlay *evaluation* (markup) | **Tariffs** (step 4/5) | O3 | +| PriceWindow store / state machine / events | **Pricing** (Slice 7, D-03) | W0–W2 | +| `pricingSnapshotRef` composition SoR | **Tariffs**; pricing pre-stamps catalog subset; Subscriptions freezes binding | S1 | +| Model-kind → formula mapping | **Pricing** (§17.2 SoR); Tariffs evaluates | M1 | +| Windowed `Q` aggregation | **Rating** (single-writer); Tariffs consumes frozen `Q` | M7 | +| Raw usage SoR (measurement) | **Usage Collector** (built v1); Rating normalizes into its own `usage_record` (mediation) | UC1 | +| Usage → Rating transport | **Joint — unpinned**; proposed: collector phase-2 emission surface (outbox stream) | UC1, UC5 | +| UsageType ↔ meter binding + dimension carrier | **Joint — unpinned**; proposed: registry/pricing declares `usageTypeRef` on the meter; `dimensionKey` rides declared `metadata_fields` | UC3, M6, RG2 | +| Commitment pools (drawdown/overage) | **Contracts** SoR; Tariffs evaluates step 6 | M8 | +| Prepaid credit wallet grant | **Pricing** authors, **Billing/Rating** executes | M8 | +| Reserved rate: self-service | **Pricing** (catalog snapshot) | M9 | +| Reserved rate: negotiated RI | **Contracts** (step-5 overlay) | M9 | +| `prorationBasis` / `billingAnchorPolicy` enums | **Pricing** (canonical); Tariffs adopts verbatim | P1, P2 | +| Proration/plan-change *math* | **Tariffs** evaluates; **Subscriptions** executes | 6.11 | +| FX math (step 8) | **Tariffs/PLAL** | aligned | +| Tax calc / display basis | **Tax Engine** (calc) / **Pricing** (display basis, D-01) | aligned | +| Two-person publish workflow | **Pricing** Slice 5 (single engine); Tariffs registers validators | G1 | +| Audit trail / retention | **Pricing** (`pricing_audit_log`, D-14) | aligned | +| Rev-share normalization (publish) | **Pricing** (D-07) | B1 | +| Bundle component summing (eval) | **Tariffs** | B1 | +| Floor/cap execution + rounding | **Billing** | aligned | +| ASC 606 recognition | **Finance/Billing** (Tariffs emits refs, null@MVP) | ASC | + +--- + +## Governance topology note (G1 — why ledger is not the merge target) + +Three maker-checker engines exist in adjacent bounded contexts; they share the *pattern*, not +the *policy*: +- **Ledger** — `dual_control_policy` + **Finance Approver**; governs financial **postings** (JE, + refund > threshold, backdating, period reopen, suspense clearing, GL write-off). Materiality = + amount/entity thresholds + backdating days. Subject = posted journal entries. +- **Pricing Slice 5** — `MaterialityEvaluator` + `ApprovalWorkflow` + a **separate** `approval_policy` + resource; **FinanceReviewer** approver; governs catalog **publish** (price/plan deltas + + always-material triggers). Pricing already cites the ledger `dual_control_policy` as **precedent** + (`design/05:262`) and deliberately keeps its own policy resource. +- **Tariffs** — folds into pricing Slice 5 (same subject: catalog publish). + +Merging catalog-publish governance into the ledger is a category error — different subject +(commercial config vs financial posting), approver role, and materiality; a price change becomes a +posting only later via Rating→Billing→Ledger. No shared platform governance library exists today +(`gears/bss/libs/` holds only `coord`). **Optional post-launch:** extract a shared maker-checker/audit +mechanism into `libs/` (à la `libs/coord`) that pricing + tariffs + ledger instantiate, each with its +own policy — DRY the mechanism, not the policy. Not a launch blocker. + +## Decisions register (to close before Design lock) + +**Resolved this session (2026-07-10):** +- **M3** — `package` pricing: **Tariffs adds a package FR** (parity with pricing p2 launch). +- **M5** — composite/derived meter: **in launch**; Tariffs adds a composite-formula eval FR. +- **G1** — governance: **single pricing Slice 5 engine**; Tariffs registers validators; ledger separate. +- **O3** — step-4 overlay composition: **stack all survivors**; class order = tie-break, not exclusivity. +- **W2** — open-period re-resolution: **Tariffs replays the pinned snapshot** (no pricing read-model change). +- **S1** — canonical `pricingSnapshotRef` field list fixed, per-segment writer ownership (Tariffs = composition SoR). +- **M7** — canonical tier-counter `Q` key = `(subscription, meter, dimensionKey, window)`. + +**Resolved 2026-07-11 (Design pass, tri-review closures — T-D-09…T-D-15 in [`DECISIONS.md`](./DECISIONS.md)):** +- **S1 residue** — eighth `commitmentReservation` snapshot segment (T-D-09). +- **Balance write-back / sequencing** — Rating publishes `CommitmentBalanceEffect`s; Contracts serializes per-pool `balanceVersion`; balance-affecting corrections cascade delta-only re-resolution of later units (T-D-10; cross-PRD obligation on Contracts/Rating). +- **Delta-dedup owner** — Rating (T-D-11). +- **Windowed-model continuity across intra-window boundaries** — per-slice sub-window units + frozen `bandOffsetQ`, adopting pricing `inst-tb-window-continuity`; correction key gains the slice coordinate (T-D-12). +- **Step-6 recompute** — steps 3–5 re-run as a unit over the reservation remainder; tier-counter exclusion + pool asymmetry registered in Tariffs PRD §6.6 / AC 19 (T-D-13). +- **Commitment pool flavors + true-up formulas** — frozen `poolType ∈ {prepaid_drawdown, committed_rate}`; sale bills outside PLAL (T-D-14). +- **Period-driven units** — Rating's period tick synthesizes recurring/capacity/true-up units at `AnchorPeriod` boundaries (T-D-15). + +**Product / commercial calls (owner sign-off) — still open:** +- **M6** — dimensional pricing launch posture (empty-tuple at launch either way; wording + declaration ownership). Proposed: declaration+freeze in-scope now, value-pricing OSS-gated. + +**Technical residues — still open (from the 2026-07-11 slice review):** +- **S1 segment naming** — **RESOLVED 2026-07-11 (T-D-09)**: the step-6 frozen identifiers form the eighth named segment `commitmentReservation` (writer: Tariffs @ eval; no pricing-side change), recorded identically in tariffs `design/01` §4.3, `design/05` §4.1, and `design/11` §4.7. +- **Composite × dimensions input-join rule** — pin with pricing before dimensional values and composite meters co-occur (tariffs `design/03` §3.6). +- **Mixed-settlement coupon rules** — `exclusive_best` cross-currency benefit comparison and mixed-settlement `ordered_stack` folding: both fail-closed until pinned with Promotions (tariffs `design/06` §4.2). +- **Seat-change boundary transport** — Subscriptions-driven change boundary vs Subscriptions-side proration for mid-period `subscription_seat_count` changes (tariffs `design/09` §4.3; default: change-boundary). +- **Bundle-level coupon attachment** — bundle-total coupon scope vs component-line attachment (tariffs `design/10` §4.5; until pinned: component lines only). +- **Usage-Collector ingestion bridge (2026-07-16)** — UC1–UC6 (§J): emission surface / transport (UC1, gates pipeline slice-12 implementation), completeness watermark (UC2), attribution join incl. dimension carrier (UC3, closes the M6 emission-shape half), dedup-key derivation (UC4), correction visibility (UC5), temporal shape / interval kind (UC6: chunked-delta bridge now, native interval kind phase-2+; gauge not rateable at launch) — pinned jointly with the usage-collector gear; proposal drafted 2026-07-16: **`gears/system/usage-collector/docs/PRD-phase2-usage-event-feed.md`** (Usage Event Feed + ingestion watermarks; executes the collector's reserved DESIGN §4 hooks, additive under its ADR-0006; closes UC1/UC2/UC5 and the UC4 key-derivation rule on adoption; UC3 attribution join stays with registry/pricing). + +**Propagation status:** all Tariffs-adopts items below + the resolved decisions (M1–M5, O1–O3, K1–K5, P1–P2, W1, F1, S1, M7, M9–M11, B1, C1, N1, M6) are **propagated into `PRD.md`** (2026-07-10, 5 assert-anchored batches; `cfs validate` + `validate-toc` green). A 4-agent fresh-eye consistency pass then closed the propagation residues (stale ACs 2/6/14, §2.2 key, §6.3 scope-mapping, governance FR/NFR/AC self-workflow, §17.3/17.4 composite-Follow-on, model-coverage lists, gear name) and added joint-fixture ACs 5a/5b/5c for per_unit / package / composite. Design lock still needs pricing-side confirmation of O3 (tie-break-not-exclusivity) and the M6 wording. **Pre-existing upstream note (not our residue):** §5.1 AC cross-references (AC 21/22/23 etc.) don't match §12 numbering (max AC 20) — a vendored-PRD numbering drift to reconcile during Design. + +**Tariffs-adopts (mechanical conformance — no product/pricing decision):** +- K1, K2, K3, K4, K5, O1, O2, W1, M1, M2 (launch-blocking), M4 (delete), M9, M10, M11, P1 (live CI gate), P2, F1, B1, C1, N1 diff --git a/gears/bss/rating/docs/design/01-foundation.md b/gears/bss/rating/docs/design/01-foundation.md new file mode 100644 index 000000000..0a71403d0 --- /dev/null +++ b/gears/bss/rating/docs/design/01-foundation.md @@ -0,0 +1,339 @@ + + + +# DESIGN — Evaluation Foundation (pure-function core) (Slice 1) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-foundation` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Adopted Canonical Scope Key (normative)](#41-adopted-canonical-scope-key-normative) + - [4.2 Determinism and Idempotency Contract (normative)](#42-determinism-and-idempotency-contract-normative) + - [4.3 pricingSnapshotRef Composition (normative)](#43-pricingsnapshotref-composition-normative) + - [4.4 Emission Guards (normative)](#44-emission-guards-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The Evaluation Foundation is the pure-function core every Rating capability runs *through* — +the mirror image of the pricing gear's *publish through the engine*: here the contract is +**evaluate through the core**. It owns the **evaluation pipeline** (the invariant §17.1 step +order with registered step evaluators), the **evaluation context** and **evaluation unit** +shapes, the **adopted** pricing 8-axis canonical scope key, the **determinism contract** +(byte-identical replay over frozen inputs), `pricingSnapshotRef` **composition** (Rating is +the composition SoR), the usage/delta **idempotency** keys, and the **emission guards** +(non-negative line, full-precision emission, no rounding). It owns **no step policy**: what a +tier band means, how overlays stack, when a coupon applies — all live in the step slices +(02–07), each of which registers its evaluator into the pipeline under the invariants defined +here. + +Everything commercial arrives **frozen**: the catalog read model pinned by `pricingSnapshotRef` +(pricing gear), the window-aggregated quantity `Q` (Rating, single-writer), the FX table +version (Finance), coupon snapshots (Promotions), and `periodState` (Billing). The Foundation +never queries mutable catalog state on the hot path; open-period corrections **replay the +pinned snapshot** (SEAMS W2). There is no authoritative store in rating-core — the resolved outcome +plus its snapshot ref *is* the output, and Rating owns its persistence +([`../PRD.md`](../PRD.md) §1.1, §4.1, §6.1). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-deterministic-evaluation-api` | One conceptual `evaluate(ctx, t)` contract: frozen context in → **resolved price outcome** (rates, model kind, tier thresholds, overlay winners) + `pricingSnapshotRef` + evaluation metadata out; replay-safe by construction (§4.2). | +| `cpt-cf-bss-rating-fr-evaluation-order` | The pipeline hard-codes the §17.1 step order (1 composition/phase → 2 base row → 3 meter/granularity → 4 overlay stack → 5 contract → 6 commitment/reservation → 7 coupon → 8 FX → 9 emit); step evaluators register into fixed slots; **there is no reordering configuration surface at all**. | +| `cpt-cf-bss-rating-fr-single-outcome-determinism` | Determinism is stated over the **evaluation unit** (§4.2): per-event record or window-aggregated `Q` per `(subscription, meter, dimensionKey, window)`; given `(window-aggregated inputs, pricingSnapshotRef, fxTableVersion)` the monetary outcome is byte-identical across replay/recompute/regions; concurrent re-resolve serializes on the partition key. | +| `cpt-cf-bss-rating-fr-snapshot-carry` | The `SnapshotComposer` assembles the canonical field list (§4.3): pricing pre-stamps the catalog subset, Subscriptions freezes the `(currency, region)` binding, the pipeline appends the eval-time segments (overlay ids, coupon ids + stacking, FX lock, commitment/reservation set); every emission carries stable `{skuId, planId, priceId}`. | +| `cpt-cf-bss-rating-fr-idempotency` | Two key families (§4.2): the usage idempotency key (Rating dedup authoritative) and the **correction key** `(window[, slice], prior-rated-version, snapshot)` for deltas — a re-rate retry replays, never double-adjusts; delta dedup enforced by Rating (§2.2). | +| `cpt-cf-bss-rating-fr-non-negative-price` | The `EmissionGuard` clamps/credits a would-be-negative resolved line after steps 4–7 and **before** period-level floor/cap (§4.4); clamp-vs-credit policy is a §15 open. | +| `cpt-cf-bss-rating-fr-separation` | The core is side-effect-free: it never mutates Usage or posted invoices; retro outcomes leave as deltas via the Adjustment path; reversal math (pool refill, `Q` decrement) is delegated to slices 05/08 under the §4.2 keys. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` (p95 ≤ 100ms lookup, < 1s path, ≥ 10M ev/day/region) | Pipeline + pinned-snapshot resolution | Pure-function hot path over a pre-resolved snapshot; no I/O inside steps; lookup cost is the read-model pin, not evaluation | Load test; **targets provisional — NFR workshop** ([`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partitioning contract | Per-partition ordering on `(subscription, meter, dimensionKey, window)`; zero cross-partition locks; re-resolve serializes per key | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Idempotency keys + fail-closed | Retries replay the same keys to the same outcome; missing input (`periodState`, coupon policy, FX record) fails closed, never guesses | Chaos/retry test | +| Decimal precision of emitted amounts | `EmissionGuard` | Amounts emitted at full intermediate precision; **the concrete DECIMAL precision is fixed in this Design before lock** ([`../PRD.md`](../PRD.md) §4.1) | **Open — set with Billing** | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Adopt the pricing 8-axis canonical scope key verbatim (selection + non-overlap); cohort generation selected by the pinned price id; no Rating-local key (SEAMS K1–K5). | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | The key definition itself — the manifest key extended additively; the pricing gear is its SoR. | +| `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` (adopted) | `cohort` = the cutover instant; Rating resolves the generation by the cohort of the subscription's pinned price id. | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (adopted) | `PriceWindow*` events are produced by the pricing gear; Rating consumes all four (incl. `Cancelled`) as read-only resolution inputs. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-fnd` + +```text +Step evaluators (slices 02–07) selection-eligibility · metering-models · overlays · + │ (register into fixed §17.1 slots) commitments · coupons · fx + ▼ +Evaluation pipeline (Foundation) EvaluationPipeline · ScopeKeyAdapter · SnapshotComposer · + DeterminismGuard · EmissionGuard · MetadataRecorder + │ + ▼ +Frozen inputs (external SoRs) pinned catalog read model (pricing) · windowed Q (Rating) · + FX tables (Finance) · coupon snapshots (Promotions) · + periodState (Billing) · phase/eligibility ctx (Subscriptions) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Step evaluators contributed by slices 02–07; period wrappers 08–09 | Rust modules in the `rating` gear (rating-core crate) | +| Domain | The pipeline, context/unit/outcome shapes, adopted key, determinism + snapshot contracts | Rust; GTS + Rust domain structs | +| Infrastructure | **None authoritative** — a non-authoritative resolved-window cache invalidated by `PriceWindow*` events; metadata/lineage emitted with the outcome | In-process cache; Rating persistence | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Pure function, frozen inputs + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-pure-function-fnd` + +No I/O and no mutable-state read inside the pipeline; every commercial input is frozen before +entry (snapshot pin, windowed `Q`, `fxTableVersion`, coupon snapshot, `periodState`). Absence +of a required input fails closed — never a guess, never a default. + +#### One order, one outcome + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-one-order-fnd` + +The §17.1 step order is compiled in, not configured. Given one evaluation unit and one frozen +input tuple there is exactly one byte-identical outcome (§4.2), on every worker, in every +region, at any later replay time. + +#### Adopt the SoR, compose the snapshot + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-adopt-compose-fnd` + +The scope key, windows, price overlays, and publish governance are the pricing gear's; Rating +adopts them verbatim (§4.1) and owns exactly one artifact of record: the **composed** +`pricingSnapshotRef` (§4.3). + +### 2.2 Constraints + +#### No authoritative store + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-store-fnd` + +rating-core persists nothing authoritative (§3.7). The resolved outcome is handed to Rating; +commitment balances live in Contracts; `Q` lives in Rating; catalog state in the pricing gear. + +#### UTC and ISO 4217 minor-unit inputs + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-utc-money-fnd` + +All effective dating, window boundaries, and anchor math are UTC. Price amounts arrive as ISO +4217 minor units from the snapshot; the pipeline computes at full intermediate precision and +never rounds (Billing rounds — §4.4). + +#### Delta dedup owner: Rating + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-delta-dedup-owner-fnd` + +The correction key (§4.2) makes deltas idempotent; the **owner of delta dedup is Rating** +(**decided 2026-07-11, T-D-11** — it already owns usage dedup, the `Q` store, and rated-output +persistence). Enforcement: Rating persists delta outcomes keyed by the correction key; a retried +delta with a known key returns the recorded outcome and is never re-emitted downstream; Billing +additionally treats Adjustment consumption as idempotent on the same key (defense in depth). +This satisfies the PRD §6.1 requirement that the owner be named before the Adjustment path ships. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-fnd` + +- **`EvaluationContext`** — the frozen input aggregate: tenant axes (`resourceTenantId`, `payerTenantId`, `sellerTenantId`), subscription/plan linkage + active `phase_id`, SKU/meter + `dimensionKey`, quantity or time slice (post-granularity), `tierAggregationWindow`, `t` (UTC), currency/region/brand scope, `periodState`, optional `reservationMatch`, optional `(changeEffectiveAt, changeMode)`, and the snapshot identifiers. +- **`EvaluationUnit`** — what determinism quantifies over, three kinds (§4.2): a single normalized `UsageRecord` (`per_event`); the window-aggregated `Q` for `(subscription, meter, dimensionKey, window)` — per sub-window slice when the window is split; a **period-driven unit** (recurring lines, capacity-flavor charges, true-up surfacing) keyed `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)`. +- **`ResolvedPriceOutcome`** — effective rates, model kind, tier thresholds, overlay winners + stack lineage, commitment/reservation effects, applied coupon ids + pre/post amounts, FX policy record, `performanceObligationRef`/`sspSnapshotPointer` (nullable), and the composed `pricingSnapshotRef`. +- **`pricingSnapshotRef`** — the three-writer composite (§4.3); immutable once emitted. +- **Obligations** — `TrueUpObligation` and `PeriodFloorCapObligation` value objects (shapes owned by slices 05/09; the Foundation defines only their emission envelope). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-evaluation-core-fnd` + +- **`EvaluationPipeline`** — composes the registered step evaluators in the fixed §17.1 order; short-circuits fail-closed on any step error. +- **`ScopeKeyAdapter`** — materializes the adopted 8-axis key from context + snapshot (§4.1): full-key window selection, eligibility class order, cohort-by-pinned-price-id. +- **`SnapshotComposer`** — verifies the pricing pre-stamp + Subscriptions binding, appends the eval-time segments, seals the ref (§4.3). +- **`DeterminismGuard`** — derives the usage/correction idempotency keys, enforces partition-key serialization for re-resolve, and stamps the frozen-input digest into metadata so replay divergence is detectable (§4.2). +- **`EmissionGuard`** — non-negative clamp/credit, full-precision emission, rounding-policy id record, obligation envelope (§4.4). +- **`MetadataRecorder`** — evaluation metadata + discount lineage (pre/post overlay, pre/post coupon, applied ids) for Billing/Tax gross-vs-net and audit. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-evaluate-fnd` + +The **evaluation contract** (conceptual; consumed by Rating in-process): `evaluate(EvaluationContext) → ResolvedPriceOutcome`. Replay-safe; same context + same frozen inputs ⇒ byte-identical outcome. Failure modes are fail-closed problem values (no eligible window on the full key; injectivity violation; missing `periodState` / coupon policy / FX record; snapshot integrity failure) — the concrete error taxonomy is defined in this Design set, not the PRD. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-reresolve-fnd` + +The **re-resolution contract**: `reresolve(window, pinnedSnapshotRef, priorRatedVersion) → deltas`. Strictly snapshot-replayed (no live catalog read); carries the correction key; emits only deltas. + +External integration contracts (Rating handoff, pricing read-model input, Subscriptions input, Finance/Promotions/Billing) are owned by [`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +None upstream — this is the root slice. Slices 02–07 register step evaluators; 08–09 wrap the +per-line flow (retroactivity, period obligations); 10 registers publish validators +(pricing-side pipeline, not here); 11 formalizes the boundary contracts. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing (Product Catalog) | pinned read model (key, windows, model kinds, bands, enums), `PriceWindow*` + `CatalogVersionPublished` events | [`11-consumer-contracts.md`](./11-consumer-contracts.md); SEAMS C1 | +| Rating | windowed `Q` (single-writer per partition key), usage dedup | PRD §9.2 Rating handoff | +| Subscriptions | `phase_id`, eligibility inputs (`activatedAt`, bound cohort), seat count, `(changeEffectiveAt, changeMode)` | PRD §9.2 Subscriptions input | +| Finance | FX tables + lock policy (`fxTableVersion`) | PRD §9.2 Finance FX | +| Promotions | frozen coupon snapshots | PRD §9.2 Promotions | +| Billing | `periodState`; executes obligations + rounding | PRD §9.2 Billing | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-evaluate-line-fnd` + +**Evaluate one line** (implements `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. Assemble `EvaluationContext`; verify every frozen input is present (fail closed otherwise). +2. Steps 1–2 (slice 02): resolve `phase_id`; select the single window on the full 8-axis key; eligibility class order; cohort by pinned price id. +3. Step 3 (slice 03): map `(meter, dimensionKey)` injectively; granularity round-up on the merged measure; model formula over the evaluation unit. +4. Steps 4–5 (slice 04): stack scope-matching PriceOverlays (class order breaks ties); apply contract overlay; enforce the anti-drift cap. +5. Step 6 (slice 05): reservation match first — a consumption split re-runs steps 3–5 as a unit over the on-demand remainder (T-D-13) — then commitment-pool waterfall; obligations surfaced, never posted. +6. Step 7 (slice 06): coupons per stacking policy (price-currency before FX). +7. Step 8 (slice 07): FX per policy; record `fxTableVersion`/lock; billing-currency coupons after. +8. Step 9 (Foundation): `EmissionGuard` (non-negative → full precision → rounding-policy id), `SnapshotComposer` seals the ref, `MetadataRecorder` attaches lineage; hand the outcome to Rating. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-reresolve-open-window-fnd` + +**Open-period re-resolution** (implements `cpt-cf-bss-rating-seq-open-period-reresolve`): + +1. Late/corrected usage lands in Rating; the window's `Q` is re-materialized (Rating, single-writer). +2. `reresolve` replays the **pinned** snapshot for the whole window unit (no live read; serialized on the partition key). +3. Diff against `prior-rated-version` → deltas only, keyed `(window[, slice], prior-rated-version, snapshot)`. +4. `periodState = closed_posted` routes the same diff through posted-period protection (slice 08) instead. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-fnd` + +**None owned.** The Foundation holds no authoritative table. The only local state is a +**non-authoritative resolved-window cache** (pinned read-model pages keyed by snapshot ref; +invalidated by `PriceWindowScheduled/Activated/Expired/Cancelled` and `CatalogVersionPublished`) +whose loss degrades latency, never correctness. Authoritative state map: catalog → pricing +gear; `Q`/dedup/rated output → Rating; commitment balances → Contracts; FX → Finance; coupon +entities → Promotions; period/rounding → Billing. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-fnd` + +The evaluation core is **`rating-core`**, a pure, I/O-free crate inside the one `rating` gear +deployable (ADR-0002 / T-D-16 — this supersedes the earlier placement note that treated the evaluation +core as a logical module of the BSS Rating deployable; the one-deployable, no-separate-evaluation-service constraint is +preserved and strengthened to a compiler-checked crate boundary + CI deny-list). Horizontal +scale is per-partition `(subscription, meter, dimensionKey, window)` with zero cross-partition +locks; the resolved-window cache is per-instance and safely cold-startable. + +## 4. Additional Context + +### 4.1 Adopted Canonical Scope Key (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-adopted-key-fnd` + +Selection and non-overlap use the pricing canonical key **verbatim**: + +```text +(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort) +``` + +- "At most one window matches" holds **only on the full key**; coexisting hybrid `chargeKind` rows and grandfathering `cohort` generations are disambiguated by the key, never fail-closed. +- `phase` is a `phase_id` (uuid; kind names are display-only); usage rows are phase-invariant by default, phase-specific wins — the no-gap rule applies to the *resolved* set. +- Eligibility class order: `existing_grandfathered > new_subscriptions_only > all_subscriptions`. Within `existing_grandfathered` the generation is the row whose `cohort` equals the cohort of the subscription's **pinned price id** in `pricingSnapshotRef` — never `activatedAt` alone. +- Definition SoR: pricing `ADR/0001` + `ADR/0002`; adoption rationale: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md). Guarded by the joint fixture set (hybrid + multi-generation). + +### 4.2 Determinism and Idempotency Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-determinism-fnd` + +- **Evaluation unit** — three kinds: `per_event` ⇒ one normalized `UsageRecord`; windowed models ⇒ the aggregated `Q` for `(subscription, meter, dimensionKey, window)` — **one unit per sub-window slice** when a slice-09 split partitions the window, coupled to earlier slices only through the frozen `bandOffsetQ` input ([`03-metering-models.md`](./03-metering-models.md) §4.3, T-D-12); **period-driven** ⇒ recurring lines, capacity-flavor charges, and period-end true-up surfacing, keyed `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)` and synthesized by Rating's period tick at anchor boundaries ([`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.1, T-D-15) — a zero-usage period still produces its period-driven units. `Q` and the per-slice attribution are materialized and owned by Rating (single writer per partition key); Rating never aggregates. +- **Frozen tuple**: `(window-aggregated inputs incl. bandOffsetQ for a sub-window slice, pricingSnapshotRef, fxTableVersion)` ⇒ byte-identical monetary outcome across replay, recompute, and cross-region workers. Every unit binds exactly **one** pinned snapshot — a split window is several units (one per slice), never one unit over several snapshots (T-D-12). The `DeterminismGuard` stamps a frozen-input digest into metadata; a divergence without an input change is a defect by definition. +- **Serialization**: concurrent re-resolve for one unit serializes on the partition key; there are no cross-partition locks. +- **Keys**: usage idempotency key (Rating dedup authoritative — same key + same snapshot never double-charges); correction key `(window[, slice], prior-rated-version, snapshot)` for every delta (retry replays, never double-adjusts) — the slice coordinate is present iff a slice-09 split partitions the window. Delta-dedup **owner**: **Rating** (§2.2, T-D-11). +- **Snapshot-only replay**: open-period re-resolution and posted-period corrections both replay the pinned snapshot; a live catalog read on a correction path is a defect (SEAMS W2). + +### 4.3 pricingSnapshotRef Composition (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-snapshot-composition-fnd` + +One ref, three writers, non-overlapping segments; **Rating is the composition SoR** and seals +the ref at emission — immutable thereafter: + +| Segment | Writer | When | +|---------|--------|------| +| `catalogVersion` (pending → committed) | pricing gear | publish / `CatalogVersionPublished` | +| resolved price ids (**incl. `cohort`**) | pricing gear | publish | +| evaluation-policy version | pricing gear | publish | +| `(currency, region)` binding | Subscriptions | activation | +| resolved overlay / `priceOverlay` ids | Rating | evaluation | +| applied coupon id(s) + stacking policy | Rating | evaluation | +| FX-lock id (if any) | Rating | evaluation | +| `commitmentReservation` — reservation match (id, flavor, `reservedQuantity`, resolved rate source); pool set (per-pool id, unit, `poolType`, balance-as-of + `balanceVersion`, draw order, rollover); reserved-vs-pool split | Rating | evaluation | + +The `SnapshotComposer` rejects (fail-closed) a context whose pricing pre-stamp or Subscriptions +binding is missing or torn; it never fabricates a segment. + +**Resolved (2026-07-11, T-D-09)**: the step-6 frozen identifiers form the **eighth named +segment** `commitmentReservation` (row above) — writer Rating @ evaluation, content sourced from +the Contracts-frozen context inputs; no new writer, no pricing-side change. Recorded identically +in [`05-commitments-reservations.md`](./05-commitments-reservations.md) §4.1, +[`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.7, and [`../SEAMS.md`](../SEAMS.md) +S1; its `balanceVersion` is the frozen balance-sequencing point of T-D-10. + +### 4.4 Emission Guards (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-emission-guards-fnd` + +Applied in order at step 9, per line: + +1. **Non-negative**: after steps 4–7 and before any period-level phase, a would-be-negative line is clamped to zero or emitted as a structured credit (clamp-vs-credit policy: PRD §15 open); a floor never masks a negative line. +2. **Full precision**: amounts leave at full intermediate precision (concrete DECIMAL precision fixed in this Design — open with Billing); the pipeline never rounds and never applies period floor/cap — Billing executes both, and the emission records the rounding-policy id. +3. **Lineage**: discount lineage (pre/post-overlay, pre/post-coupon amounts + applied ids) and ASC 606 refs (`performanceObligationRef`, `sspSnapshotPointer` — null at MVP) ride the outcome envelope. + +## 5. Traceability + +- **PRD**: §6.1 (all seven FRs above), §6.3 `fr-evaluation-order`, §7.1 NFRs, §17.1 (normative step order), §4.1 (environment constraints). +- **Seams**: K1/K3 (key), S1 (snapshot), M7 (counter key), W2 (snapshot replay), M11 (catalog guarantees) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-01, T-D-03, T-D-04 — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md). +- **Step slices**: [`02-selection-eligibility.md`](./02-selection-eligibility.md) … [`07-currency-fx.md`](./07-currency-fx.md) register evaluators; [`08`](./08-retroactivity-corrections.md)/[`09`](./09-period-plan-change.md) wrap; [`11-consumer-contracts.md`](./11-consumer-contracts.md) owns the boundary. diff --git a/gears/bss/rating/docs/design/02-selection-eligibility.md b/gears/bss/rating/docs/design/02-selection-eligibility.md new file mode 100644 index 000000000..505303c87 --- /dev/null +++ b/gears/bss/rating/docs/design/02-selection-eligibility.md @@ -0,0 +1,315 @@ + + + +# DESIGN — Base Selection & Eligibility (Slice 2) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-selection-eligibility` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Selection Algorithm (normative)](#41-selection-algorithm-normative) + - [4.2 Eligibility Classes and Cohort Generations (normative)](#42-eligibility-classes-and-cohort-generations-normative) + - [4.3 Phase Semantics (normative)](#43-phase-semantics-normative) + - [4.4 Selection Failure Taxonomy (normative)](#44-selection-failure-taxonomy-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +Base Selection & Eligibility is the **steps 1–2 evaluator** registered into the Foundation +pipeline ([`01-foundation.md`](./01-foundation.md) §3.2): resolve the active plan **phase** +(`phase_id`) at `t`, then select **the single** `Price`/`PriceWindow` row on the pricing +**8-axis canonical scope key** — per emitted line's `chargeKind`. Everything downstream (model +math, overlays, commitments, coupons, FX) prices *the row this slice selected*; a selection +defect is therefore the worst class of mispricing, and the slice's whole posture is +**fail-closed over frozen inputs**: no silent fallback, no `activatedAt` heuristics, no +kind-name phase matching. + +The slice owns the **selection policy**: candidate-set construction (including the D-15 +phase-invariant usage fallback), the `priceEligibility` **class order**, **multi-generation +`cohort` selection by the subscription's pinned price id**, the single-match assertion, and the +selection failure taxonomy. It does **not** own the key definition (pricing gear SoR, adopted +verbatim — 01 §4.1), the key-materialization mechanism (`ScopeKeyAdapter`, Foundation), overlay +stacking (step 4, [`04-overlays-precedence.md`](./04-overlays-precedence.md)), or meter mapping +(step 3, [`03-metering-models.md`](./03-metering-models.md)). Inputs arrive frozen: the pinned +catalog read model (pricing), the phase/eligibility context (Subscriptions), and the pinned +price ids with their `cohort` in the `pricingSnapshotRef` pre-stamp +([`../PRD.md`](../PRD.md) §6.3, §6.5). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-base-catalog-selection` | `SelectionEvaluator` selects on the full 8-axis key with `t in [effectiveFrom, effectiveTo)`; "at most one window matches" is asserted **only on the full key** — coexisting hybrid `chargeKind` rows and `cohort` generations are disambiguated by the key, never fail-closed (§4.1); no match on billable usage ⇒ fail-closed problem (§4.4). | +| `cpt-cf-bss-rating-fr-price-eligibility-grandfathering` | `EligibilityClassFilter` applies `existing_grandfathered > new_subscriptions_only > all_subscriptions`; `CohortGenerationSelector` picks the generation whose `cohort` equals the cohort of the subscription's **pinned price id** in `pricingSnapshotRef` — never `activatedAt` alone (§4.2). | +| `cpt-cf-bss-rating-fr-plan-phases` | `PhaseResolver` resolves the active `phase_id` (uuid, pricing D-19) at `t` from the frozen Subscriptions phase context; `CandidateSetBuilder` implements the D-15 phase-invariant usage fallback so the no-gap rule applies to the *resolved* set (§4.3). | +| `cpt-cf-bss-rating-fr-evaluation-order` (steps 1–2 slots) | The evaluator registers into the fixed step-1/step-2 slots of the Foundation pipeline; the order itself is owned by 01 — this slice contributes policy, not sequencing. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` (p95 ≤ 100ms lookup) | `CandidateSetBuilder` over the pinned read model | Selection is in-memory filtering over pinned read-model pages (Foundation resolved-window cache, 01 §3.7); no I/O, no catalog query inside the evaluator | Load test; targets provisional (NFR workshop, [`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | `SelectionEvaluator` | Stateless pure function per evaluation unit; nothing selection-side blocks per-partition parallelism | Design review | +| `cpt-cf-bss-rating-nfr-resilience` | Failure taxonomy §4.4 | Every unresolved selection is a typed fail-closed problem (no guess, no default row); retries replay to the identical outcome (01 §4.2) | Chaos/retry test + joint fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Adopt the pricing 8-axis key verbatim for selection + non-overlap; no Rating-local key (SEAMS K1–K5). | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | The key definition — 8 additive axes; the pricing gear is its SoR. | +| `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` (adopted) | `cohort` = the cutover instant; N generations coexist; the generation is selected by the pinned price id's cohort. | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (adopted) | `PriceWindow*` events (all four, incl. `Cancelled`) are read-only resolution inputs; pricing owns the window store and state machine. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-sel` + +```text +SelectionEvaluator (this slice) PhaseResolver · CandidateSetBuilder · EligibilityClassFilter · + │ (registers into §17.1 slots 1–2) CohortGenerationSelector · SelectionGuard + ▼ +Foundation mechanisms (01) ScopeKeyAdapter (key materialization) · EvaluationPipeline · + resolved-window cache · SnapshotComposer pre-stamp checks + │ + ▼ +Frozen inputs pinned catalog read model (pricing) · phase/eligibility ctx + (Subscriptions) · pinned price ids + cohort (snapshot pre-stamp) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The steps 1–2 evaluator and its selection policy | Rust module in the `rating` gear (rating-core crate) | +| Domain | Candidate set, eligibility classes, cohort pin, selection outcome shapes | Rust; GTS + Rust domain structs | +| Infrastructure | **None owned** — reads pinned read-model pages via the Foundation cache | In-process (01 §3.7) | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### The full key or nothing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-full-key-only-sel` + +Uniqueness exists **only on the full 8-axis key**. Shorter tuples are non-unique *by pricing +design* (hybrid `chargeKind` rows; grandfathered generations + successor coexisting); treating +a short-tuple multi-match as an error is itself the defect (SEAMS K1/K3). The evaluator never +selects, asserts, or fails on a partial key. + +#### Class order first, then the cohort pin + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-class-then-cohort-sel` + +Eligibility is a two-stage filter: the class order picks the most specific eligible class; +within `existing_grandfathered` the **pinned price id's `cohort`** picks the generation. The +subscription's `activatedAt` bounds class membership but **never** selects a generation +(SEAMS K2). + +#### Gaps are judged on the resolved set + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-resolved-set-no-gap-sel` + +The no-gap rule applies **after** the D-15 phase-invariant usage fallback resolves the +candidate set: a phase covered only by a phase-invariant usage row is *not* a gap; a truly +uncovered billable phase *is* — and fails closed (SEAMS F1). + +### 2.2 Constraints + +#### No silent fallback + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-silent-fallback-sel` + +If no eligible window matches on the full key, evaluation **fails for billable usage** — never +a default row, a nearest window, or a zero rate ([`../PRD.md`](../PRD.md) §6.3). The failure is +a typed problem (§4.4), surfaced with the materialized key for diagnosis. + +#### `phase` is a `phase_id` + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-phase-id-only-sel` + +The phase axis joins on a **uuid `phase_id`** (pricing D-19); kind names (trial / intro / +evergreen) are display-only and never match anything. Non-phased and one-time rows ride the +implicit **terminal `phase_id`** — the evaluator treats them as ordinary keyed rows (SEAMS K5). + +#### Selection inputs are frozen + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-frozen-selection-inputs-sel` + +The phase timeline, `activatedAt`, and the pinned price ids with their `cohort` all arrive in +the frozen `EvaluationContext` / snapshot pre-stamp; the evaluator performs **no Subscriptions +or catalog query** on the hot path (01 §2.1). Re-resolution replays the same frozen inputs +(SEAMS W2). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-sel` + +All value objects; the slice owns no entities — catalog rows are frozen inputs. + +- **`PhaseContext`** — the frozen Subscriptions phase timeline for the subscription; yields the active `phase_id` at `t` (or a typed absence). +- **`SelectionKey`** — the materialized 8-axis tuple `(planId, currency, region, priceOverlay, phase, priceEligibility, chargeKind, cohort)` produced via the Foundation `ScopeKeyAdapter`; the `priceOverlay` axis of the **base** row (overlay lists are step-4 material, slice 04). +- **`CandidateSet`** — the pinned-read-model rows admitted for `(t, phase_id ∪ phase-invariant, chargeKind)` before eligibility filtering; retains per-row provenance for diagnostics. +- **`EligibilityClass`** — `existing_grandfathered > new_subscriptions_only > all_subscriptions` (ordered; the order *is* the domain fact). +- **`CohortPin`** — the pinned price id and its `cohort` extracted from the `pricingSnapshotRef` pricing pre-stamp; absence while an `existing_grandfathered` candidate exists is a torn-pin failure (§4.4). +- **`SelectionOutcome`** — the selected row (stable `{skuId, planId, priceId}`), window identity, winning class, matched `cohort`, resolved `phase_id`, the phase-invariant-fallback flag, and the **FX-skip flag** (invoice currency = price currency ⇒ step 8 skipped) — recorded into evaluation metadata and consumed by steps 3+. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-selection-evaluator-sel` + +`SelectionEvaluator` — the registered steps 1–2 evaluator; policy lives here, mechanism in the +Foundation (`ScopeKeyAdapter` materializes the key; the pipeline sequences the slots): + +- **`PhaseResolver`** — step 1: the active `phase_id` at `t` from `PhaseContext`; fails closed on an unresolvable phase. +- **`CandidateSetBuilder`** — admits rows effective at `t` for the resolved `phase_id` **plus** phase-invariant usage rows (D-15); phase-specific wins where both cover the same remaining key (§4.3). +- **`EligibilityClassFilter`** — applies the class order; evaluates `new_subscriptions_only` / `existing_grandfathered` membership from frozen `activatedAt` vs window `effectiveFrom` / cutover (§4.2). +- **`CohortGenerationSelector`** — within `existing_grandfathered`, keeps exactly the row whose `cohort` equals `CohortPin.cohort` (§4.2). +- **`SelectionGuard`** — asserts exactly one surviving row per full key per emitted line's `chargeKind`; >1 is a catalog non-overlap violation (a **pricing-side defect**, fail-closed and surfaced, never tie-broken); 0 on billable usage is `no_eligible_window` (§4.4). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-select-base-row-sel` + +The **selection contract** (internal; invoked only by the Foundation pipeline, never a public +surface): `select(EvaluationContext, pinned read model) → SelectionOutcome | SelectionProblem`. +Deterministic: same frozen context + same pinned pages ⇒ the identical outcome, on first +evaluation and on snapshot replay (SEAMS W2). Problems are the typed fail-closed values of §4.4 +— this slice's contribution to the error taxonomy promised in 01 §3.3. + +### 3.4 Internal Dependencies + +Upstream: [`01-foundation.md`](./01-foundation.md) — pipeline slots, `ScopeKeyAdapter`, +snapshot pre-stamp integrity (a torn pre-stamp is rejected by the `SnapshotComposer` before +selection runs). Downstream: [`03-metering-models.md`](./03-metering-models.md) prices the +selected row; [`04-overlays-precedence.md`](./04-overlays-precedence.md) stacks overlays on it; +[`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) replays this same +evaluator over the pinned snapshot for corrections. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing (Product Catalog) | pinned read-model rows on the 8-axis key (windows, eligibility, cohorts); `PriceWindowScheduled/Activated/Expired/Cancelled` + `CatalogVersionPublished` as **cache-invalidation** signals only | [`11-consumer-contracts.md`](./11-consumer-contracts.md); SEAMS C1, W1 | +| Subscriptions | phase timeline / active `phase_id` inputs, `activatedAt`, the subscription's pinned price id binding | PRD §9.2 Subscriptions input | +| Pricing pre-stamp (via snapshot) | resolved price ids **incl. `cohort`** | 01 §4.3; SEAMS S1 | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-select-base-row-sel` + +**Select the base row** (steps 1–2 of `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. `PhaseResolver`: active `phase_id` at `t` from the frozen `PhaseContext` (fail closed if unresolvable). +2. `CandidateSetBuilder`: rows effective at `t` for `phase_id` ∪ phase-invariant usage rows; phase-specific wins on collision (D-15). +3. `EligibilityClassFilter`: keep the most specific eligible class per the total order (§4.2). +4. `CohortGenerationSelector`: within `existing_grandfathered`, keep the row with `cohort = CohortPin.cohort`. +5. `SelectionGuard`: exactly one row per full key per emitted line's `chargeKind` — hybrid plans select one recurring **and** one usage row, each on its own key (SEAMS K3, owned by 01's key adoption; enforced here). +6. Emit `SelectionOutcome` (+ FX-skip flag when invoice currency = price currency); record class / cohort / `phase_id` / fallback flag into evaluation metadata; hand to step 3. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-phase-fallback-sel` + +**Phase-invariant usage fallback** (SEAMS F1): a usage line lands in a phase with no +phase-scoped usage row → the phase-invariant row is selected (not a gap, no failure); where a +phase-scoped usage row exists for the same remaining key, it wins for its phase. Recorded via +the fallback flag so fixtures can assert which path priced the line. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-sel` + +**None owned.** Selection reads pinned read-model pages through the Foundation's +non-authoritative resolved-window cache (01 §3.7); cache loss degrades latency, never +correctness or selection outcome. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-sel` + +Nothing beyond the Foundation posture (01 §3.8): a module in the `rating` gear (rating-core crate); +stateless per evaluation unit; safe under per-partition parallelism. + +## 4. Additional Context + +### 4.1 Selection Algorithm (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-selection-algorithm-sel` + +For evaluation at `t` (UTC) over a frozen context, per emitted line (`chargeKind`): + +1. Resolve the active `phase_id` at `t` (step 1). Unresolvable ⇒ `missing_phase_context`. +2. Build the candidate set: rows with `t in [effectiveFrom, effectiveTo)` keyed to `phase_id`, **plus** phase-invariant usage rows; phase-specific wins on collision (§4.3). +3. Apply the eligibility class order; within `existing_grandfathered`, apply the cohort pin (§4.2). +4. Assert exactly one survivor **on the full 8-axis key**: `0` on billable usage ⇒ `no_eligible_window`; `>1` ⇒ `selection_ambiguity` (§4.4). +5. Emit `SelectionOutcome`; when invoice currency equals the row's price currency, set the FX-skip flag (advisory metadata — [`07-currency-fx.md`](./07-currency-fx.md) re-derives the skip authoritatively from `CurrencyRoles`). + +The algorithm is a pure function of `(EvaluationContext, pinned read model)`; replay and +re-resolution execute it unchanged over the pinned snapshot (SEAMS W2). + +### 4.2 Eligibility Classes and Cohort Generations (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-eligibility-cohort-sel` + +- Class order (total, most-specific-wins): `existing_grandfathered > new_subscriptions_only > all_subscriptions` (pricing `design/07`, adopted verbatim — SEAMS K4). +- `new_subscriptions_only` **excludes** subscriptions with `activatedAt` before the window `effectiveFrom`; `existing_grandfathered` includes **only** subscriptions activated before cutover. +- **Multi-generation grandfathering** (pricing `ADR/0002`, SEAMS K2): N generations — distinct `cohort`s, each an active window — legitimately coexist on one key. The generation is the row whose `cohort` equals the cohort of the subscription's **pinned price id** in `pricingSnapshotRef`. `activatedAt` alone **never** selects a generation; no new store is needed — the pin already exists. +- Outside `existing_grandfathered` the `cohort` axis is always **`none`** — the pin never participates in selecting non-grandfathered rows. +- A present pin whose `cohort` matches **no live generation** while grandfathered candidates exist is `cohort_pin_unmatched` (§4.4) — never a neighbouring-generation pick, never a silent fall-through; when no grandfathered candidate is live at `t` (all generation windows expired), the class filter naturally proceeds to the remaining classes. +- Guarded by the joint fixture set: multi-generation coexistence + hybrid selection are the cohort/phase-fallback anchor fixtures shared with the pricing gear (01 §4.1); the fixture set MUST include the unmatched-pin scenario. + +### 4.3 Phase Semantics (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-phase-semantics-sel` + +- `phase` is always a **uuid `phase_id`** (pricing D-19); trial / intro / evergreen are display names and never join the axis (SEAMS K5). +- Non-phased and one-time rows ride the implicit **terminal `phase_id`** and select like any other row. +- Distinct phases MAY have schedules coexisting at the same `t` — not an overlap: `phase` is a key axis ([`../PRD.md`](../PRD.md) §6.5). +- **Usage rows are phase-invariant by default** (pricing D-15): one usage row spans all phases; an explicit phase-scoped usage row wins for its phase. The no-gap rule applies to the **resolved** set — a phase covered only by the phase-invariant row is not a gap (SEAMS F1). Mechanically the phase-invariant usage row **is** the terminal-`phase_id` row (the D-15/D-19 reconciliation) — no separate catalog flag exists. +- **Subscription-less (quote-time) contexts** — e.g. bundle `sum_of_parts` summing at quote — carry no Subscriptions phase/eligibility inputs and default to: the plan's terminal `phase_id`, `priceEligibility = all_subscriptions`, `cohort = none`, no proration ([`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.3). + +### 4.4 Selection Failure Taxonomy (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-failure-taxonomy-sel` + +All fail-closed problem values (01 §3.3); each carries the materialized `SelectionKey` and the +candidate-set provenance for diagnosis: + +| Problem | Condition | Semantics | +|---------|-----------|-----------| +| `no_eligible_window` | zero survivors on the full key for **billable** usage | evaluation fails; no fallback, no zero-rate | +| `selection_ambiguity` | more than one survivor on the full key | catalog **non-overlap violation** — a pricing-side publish defect; fail closed and surface to the pricing gear; never tie-broken locally | +| `missing_phase_context` | no active `phase_id` resolvable at `t` | fail closed; the frozen Subscriptions input is incomplete | +| `torn_cohort_pin` | `existing_grandfathered` candidates present but the snapshot pre-stamp lacks the pinned price id / `cohort` | fail closed; snapshot integrity failure (01 §4.3 — the `SnapshotComposer` never fabricates a segment) | +| `cohort_pin_unmatched` | the pin is **present** but its `cohort` matches no candidate generation while `existing_grandfathered` candidates exist | fail closed; a lifecycle/re-bind inconsistency — **never** a neighbouring-generation pick and never a silent fall-through to `all_subscriptions` (fall-through is legal only when no grandfathered candidate is live at `t` at all — expired windows simply leave the candidate set) | + +## 5. Traceability + +- **PRD**: §6.3 `fr-base-catalog-selection` + steps 1–2 slots of `fr-evaluation-order`; §6.5 `fr-price-eligibility-grandfathering`, `fr-plan-phases`; §17.1 steps 1–2 (normative order). +- **Seams**: K1 (full 8-axis key), K2 (cohort by pinned price id), K4 (class order), K5 (`phase_id` typing), F1 (phase-invariant fallback) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-01, T-D-08 (F1 part) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md); adopted pricing ADRs per §1.2. +- **Related slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, `ScopeKeyAdapter`, snapshot pre-stamp), [`03-metering-models.md`](./03-metering-models.md) (step 3 consumes the outcome), [`04-overlays-precedence.md`](./04-overlays-precedence.md) (overlay `priceOverlay` stacking), [`07-currency-fx.md`](./07-currency-fx.md) (FX-skip flag), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (snapshot replay of this evaluator). diff --git a/gears/bss/rating/docs/design/03-metering-models.md b/gears/bss/rating/docs/design/03-metering-models.md new file mode 100644 index 000000000..f2bf5ce95 --- /dev/null +++ b/gears/bss/rating/docs/design/03-metering-models.md @@ -0,0 +1,349 @@ + + + +# DESIGN — Metering & Pricing Models (Slice 3) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-metering-models` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Model Formulas (normative)](#41-model-formulas-normative) + - [4.2 Meter Mapping and Dimensional Lines (normative)](#42-meter-mapping-and-dimensional-lines-normative) + - [4.3 Tier Aggregation Window and `Q` (normative)](#43-tier-aggregation-window-and-q-normative) + - [4.4 Granularity Round-Up (normative)](#44-granularity-round-up-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +Metering & Pricing Models is the **step 3 evaluator** plus the **model-formula library**: it +maps the evaluation unit to a charge line keyed `(meter, dimensionKey)` (injective per plan +revision, fail-closed otherwise), normalizes the measure (`billingGranularity` round-up on the +**merged** aggregate, never per raw record), resolves the tier-counter window, and computes the +model math for the catalog `modelKind` set `{flat, per_unit, graduated, volume, package}` — +the pricing §17.2 **kind→formula mapping adopted verbatim as shared SoR** (SEAMS M1). `hybrid` +and `committed` are **not** model kinds: a hybrid plan is a composition emitting two lines +under one `planId`; committed usage is a commitment pool over a base model, evaluated at step 6 +([`05-commitments-reservations.md`](./05-commitments-reservations.md)). + +The slice also owns the two cloud-defining launch capabilities riding step 3: **dimensional +lines** — each distinct `(meter, dimensionKey)` prices as its own line, with the declared +dimension set frozen in `pricingSnapshotRef` and no silent collapsing of partial values — and +**composite (derived) meter evaluation** — a catalog-declared frozen formula-as-data over ≥ 2 +input units producing one output quantity that is then priced by its own `modelKind` +(SEAMS M5). What it does **not** own: `Q` aggregation (Rating, single-writer per +`(subscription, meter, dimensionKey, window)` — SEAMS M7), dimension **declaration** authoring +(pricing catalog) and dimension **value emission** (OSS metering, the external critical path), +pool drawdown (slice 05), and period floor/cap (slice 09; bands are open-top by catalog +guarantee — capping is a period-level obligation). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-meter-mapping-granularity` | `MeterMapper` maps the unit to `(meter, dimensionKey)` injectively per plan revision (configuration error ⇒ fail closed); `GranularityNormalizer` rounds up the merged measure exactly once (§4.4). | +| `cpt-cf-bss-rating-fr-flat-pricing` | `ModelFormulaEvaluator`: `unitPrice × Q` (or fixed amount per period for recurring); no thresholds evaluated (§4.1). | +| `cpt-cf-bss-rating-fr-per-unit-pricing` | `per_unit` = `unitPrice × quantity` where quantity comes from the frozen `quantitySource` (`subscription_seat_count` from Subscriptions, or `manual`) — **never** metered `Q`; pricing **p1 launch**, joint fixture (SEAMS M2). | +| `cpt-cf-bss-rating-fr-tiered-graduated` | Marginal band math per band; a single-band graduated is numerically Variant A — distinguished by configured kind, not by math; counter per the resolved window (§4.3). | +| `cpt-cf-bss-rating-fr-volume-variant-a` | One band rate applied to **all** units by total `Q` in the window; explicit per-SKU configuration, distinguishable from graduated (§4.1). | +| `cpt-cf-bss-rating-fr-package-pricing` | `ceil(usedQ / packageSize) × packagePrice` over the window; partial block rounds up to one block; parity with pricing p2 launch, joint fixture (SEAMS M3). | +| `cpt-cf-bss-rating-fr-hybrid-pricing` | Composition, not a kind: two lines under one `planId`, independently evaluated; min-commit expressed as committed-usage, never conflated with a period floor; attachment configuration frozen in `pricingSnapshotRef` (§4.1). | +| `cpt-cf-bss-rating-fr-committed-usage` | Composition over a base model: in-commitment vs overage rates and `TrueUpObligation` are step 6 ([`05`](./05-commitments-reservations.md)); reversal/refill under slice [`08`](./08-retroactivity-corrections.md) keys. This slice contributes only the base-model math the pool wraps. | +| `cpt-cf-bss-rating-fr-dimensional-pricing` | `MeterMapper` prices each distinct `(meter, dimensionKey)` as its own line; empty/partial values on a dimension-declaring plan route to a **published** default/catch-all line or fail closed — never guessed (§4.2). | +| `cpt-cf-bss-rating-fr-dimension-population-contract` | Declaration = catalog; **freeze = this slice** (declared set into `pricingSnapshotRef`); value emission = OSS metering (external critical path); until then `dimensionKey` is the empty tuple (§4.2). | +| `cpt-cf-bss-rating-fr-composite-meter-eval` | `CompositeMeterEvaluator` evaluates the frozen formula-as-data (window-`sum` only at launch — SEAMS M10) to the output quantity, then prices the output unit by its `modelKind`; Rating never authors or mutates the derivation (§4.1). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | `ModelFormulaEvaluator` | Formula math is O(bands) in-memory over the frozen aggregate; no I/O inside step 3 | Load test; targets provisional (NFR workshop) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | `Q` consumption | The counter key `(subscription, meter, dimensionKey, window)` **is** the partition key (SEAMS M7) — window math stays partition-local for every single-meter model; the composite meter is the one exception and reads its input `Q`s as frozen values, never live counters (§3.6) | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | `MeterMapper` fail-closed | Non-injective mapping, unresolvable dimension routing, or a missing frozen model parameter is a typed configuration failure — never a guessed line | Chaos/retry test + joint fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | The window key is the pricing 8-axis key; `(meter, dimensionKey)` keys the **line within** the selected row — it is not a window-selection axis (SEAMS K1 boundary). | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | Key definition SoR; the usage-only restriction for tier models rides the key's `chargeKind` axis (pricing D-18). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-mm` + +```text +Step 3 evaluator (this slice) MeterMapper · GranularityNormalizer · TierWindowResolver · + │ (registers into §17.1 slot 3) ModelFormulaEvaluator · CompositeMeterEvaluator + ▼ +Foundation mechanisms (01) EvaluationPipeline · EvaluationUnit shapes · frozen-input digest + │ + ▼ +Frozen inputs selected row + model params (slice 02 / snapshot) · windowed Q + (Rating) · seat count (Subscriptions) · declared dimension set + + composite formula (pricing snapshot) · dimensionKey values (OSS) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The step 3 evaluator: mapping, normalization, model math, composite evaluation | Rust module in the `rating` gear (rating-core crate) | +| Domain | Line key, normalized measure, band/package/per-unit parameter shapes, window spec | Rust; GTS + Rust domain structs | +| Infrastructure | **None owned** — `Q` and dedup live in Rating; model params arrive in the pinned snapshot | In-process (01 §3.7) | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### The kind→formula mapping is shared SoR + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-shared-formula-sor-mm` + +The catalog `modelKind` enum and its formula semantics are the pricing §17.2 mapping adopted +verbatim (SEAMS M1); Rating implements, never redefines. Commercial constructs that are *not* +kinds (hybrid, committed) are compositions over kinds — reclassifying them as kinds is the +defect this principle guards against. + +#### Price the aggregate, not the record + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-aggregate-not-record-mm` + +All windowed math — granularity round-up, tier placement, package blocks, composite sums — +operates on the **window-aggregated, merged measure** (`Q` for +`(subscription, meter, dimensionKey, window)`), never on raw `UsageRecord`s. Rating aggregates +(single-writer); Rating prices the normalized aggregate (SEAMS M7). + +#### Never guess a line + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-never-guess-line-mm` + +An ambiguous meter mapping, a partial dimension tuple, or a missing frozen parameter routes to +an explicitly **published** default line or fails closed — silent collapsing of dimensional +usage into one line is mispricing by construction ([`../PRD.md`](../PRD.md) §6.7). + +### 2.2 Constraints + +#### Catalog guarantees relied on + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-catalog-guarantees-mm` + +Tier bands are **always open-top** (pricing D-17: no closed top, no above-max fail-closed +branch — capping is a period-level obligation, slice [`09`](./09-period-plan-change.md)); +`graduated` / `volume` / `package` are **usage-only** (`chargeKind=usage`, pricing D-18); +launch aggregation is `aggregationFunction = sum` only (pricing D Q2, SEAMS M10/M11). The +evaluator presupposes these guarantees and carries no code paths for their violation. + +#### Quantity sources are typed + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-typed-quantity-mm` + +Metered `Q` (Rating), `subscription_seat_count` (Subscriptions), and `manual` are distinct +frozen quantity sources; `per_unit` never reads `Q`, usage models never read seat count. The +`quantitySource` is frozen in `pricingSnapshotRef` (SEAMS M2). + +#### Dimension declaration is not dimension emission + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-declare-vs-emit-mm` + +The catalog persists the declared dimension set; this slice **freezes** it in the snapshot; +Rating passes `dimensionKey` through; OSS metering emits values (external upstream, critical +path — [`../PRD.md`](../PRD.md) §16, §17.3). Until OSS emits, `dimensionKey` stays the empty +tuple. Cross-doc wording of the launch posture is the **open seam M6** +([`../SEAMS.md`](../SEAMS.md)). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-mm` + +All value objects; model parameters are frozen snapshot content, never authored here. + +- **`ChargeLineKey`** — `(meter, dimensionKey)`; injective per plan revision; the empty tuple for plans declaring no dimensions. +- **`NormalizedMeasure`** — the merged, granularity-rounded quantity of the evaluation unit (01 §4.2); carries the pre-round raw aggregate for lineage. +- **`ModelParams`** — the frozen per-kind parameter set: `unitPrice` (flat/per_unit), open-top marginal bands (graduated/volume A), `packageSize`/`packagePrice` (package), `quantitySource` (per_unit). +- **`TierWindowSpec`** — the resolved `tierAggregationWindow` value + concrete UTC boundaries (anchor policy applied); recorded in metadata and frozen in the snapshot. +- **`QCounterRef`** — the window-aggregated `Q` for `(subscription, meter, dimensionKey, window)` — Rating-owned, consumed frozen; never written here. +- **`CompositeFormula`** — the frozen formula-as-data: input unit set (≥ 2), window-`sum` derivation, output unit; catalog-declared (pricing Slice 10). +- **`ModelLineOutcome`** — per-line model result: effective rate(s), band/block placement, quantities in/out, feeding steps 4+ and the outcome lineage. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-metering-models-mm` + +The step 3 evaluator, registered into the fixed slot (01 §3.2): + +- **`MeterMapper`** — evaluation unit → `ChargeLineKey`; asserts injectivity per plan revision (violation ⇒ fail-closed configuration error); routes empty/partial dimension tuples on a dimension-declaring plan to the published default/catch-all line or fails closed (§4.2). +- **`GranularityNormalizer`** — applies `billingGranularity` round-up once, on the merged measure (§4.4); records the granularity in metadata. +- **`TierWindowResolver`** — resolves `tierAggregationWindow` to concrete UTC boundaries: `calendar_month` in UTC; `invoice_period` per the frozen `billingAnchorPolicy` + D-20 clamp; `subscription_lifetime`; `per_event` (§4.3). +- **`ModelFormulaEvaluator`** — the five kind formulas (§4.1) over `NormalizedMeasure` / `QCounterRef` and `ModelParams`; full intermediate precision, no rounding (01 §4.4). +- **`CompositeMeterEvaluator`** — evaluates `CompositeFormula` to the output quantity, then delegates the output unit to `ModelFormulaEvaluator` under its own `modelKind` (§4.1). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-price-line-mm` + +The **step 3 contract** (internal; pipeline-invoked): `price_line(SelectionOutcome, +EvaluationUnit, frozen params) → ModelLineOutcome | ModelProblem`. Deterministic over the +frozen tuple (01 §4.2). Problems (this slice's rows in the Design-set error taxonomy): +`non_injective_mapping`, `unroutable_dimension_tuple`, `missing_model_param`, +`unknown_model_kind` (enum drift — guarded upstream by the CI gate, SEAMS P1 pattern) — all +fail-closed. + +### 3.4 Internal Dependencies + +Upstream: [`01-foundation.md`](./01-foundation.md) (pipeline, evaluation unit, precision +guards); [`02-selection-eligibility.md`](./02-selection-eligibility.md) (the selected row and +its `modelKind`/params provenance). Downstream: [`04-overlays-precedence.md`](./04-overlays-precedence.md) +stacks overlays on the model output; [`05-commitments-reservations.md`](./05-commitments-reservations.md) +wraps it in pool/reservation math; [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) +drives the correction-time counter decrement (Rating executes the decrement; the key is M7's). + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Rating | window-aggregated `Q` per `(subscription, meter, dimensionKey, window)` (single-writer); merged session measures for continuous-duration meters | PRD §9.2 Rating handoff; SEAMS M7 | +| Pricing (Product Catalog) | `modelKind` + per-kind params, declared dimension set, composite formula, `tierAggregationWindow` / `billingAnchorPolicy` — all in the pinned snapshot | [`11-consumer-contracts.md`](./11-consumer-contracts.md); SEAMS M1/M3/M5 | +| Subscriptions | `subscription_seat_count` for the `per_unit` `quantitySource` | PRD §9.2 Subscriptions input; SEAMS M2 | +| OSS Metering | `dimensionKey` **values** on usage (external critical path; empty tuple until delivered) | PRD §6.7, §17.3; SEAMS M6 (open wording) | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-price-line-mm` + +**Price one line** (step 3 of `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. `MeterMapper`: evaluation unit → `ChargeLineKey`; injectivity assert; dimension routing (§4.2). +2. `GranularityNormalizer`: round the merged measure up to `billingGranularity` — exactly once (§4.4). +3. `TierWindowResolver`: resolve the window and its UTC boundaries; for windowed models bind `QCounterRef` (§4.3). +4. `ModelFormulaEvaluator`: apply the kind formula (§4.1) at full intermediate precision. +5. Emit `ModelLineOutcome` (+ granularity, window value, band/block placement into metadata); hand to step 4. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-composite-eval-mm` + +**Composite (derived) meter**: evaluate the frozen formula (window-`sum` over ≥ 2 input units) +→ one output quantity → the output unit's line is priced by its own `modelKind` through the +same steps; formula, input set, and output unit are frozen in `pricingSnapshotRef`; Rating +never authors or mutates the derivation (SEAMS M5). + +**Composite partition rule**: the input `Q`s come from ≥ 2 *different* meters — different +partition keys — making the composite the one launch case where an evaluation unit reads across +counter partitions. The input `Q`s enter the frozen tuple as ordinary frozen inputs (digested by +the `DeterminismGuard`, 01 §4.2); the composite line itself partitions on +`(subscription, outputUnit, dimensionKey, window)`; a late-arrival change to **any** input `Q` +triggers re-resolution of the composite line under the slice-08 correction keys. Reads are of +frozen values, never live counters — no cross-partition lock exists. + +**Composite × dimensions**: at launch the two do not co-occur (`dimensionKey` is the empty tuple +until OSS emission — SEAMS M6). The input-join rule for dimension-carrying inputs (join on the +matching tuple vs a formula-declared join) MUST be pinned jointly with the pricing gear before +both are live — tracked open. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-dimension-routing-mm` + +**Dimension routing**: a record with empty/partial dimension values on a dimension-declaring +plan → the explicitly **published** default/catch-all line if defined, else fail-closed +(reject/quarantine) — never silently priced as a single line ([`../PRD.md`](../PRD.md) §6.7). + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-mm` + +**None owned.** `Q` and usage dedup live in Rating; model parameters, dimension declarations, +and composite formulas live in the pinned catalog snapshot; nothing model-side persists in +Rating (01 §3.7). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-mm` + +Nothing beyond the Foundation posture (01 §3.8). The counter key doubling as the partition key +(SEAMS M7) is what keeps step 3 horizontally scalable — single-meter window math is +partition-local by construction; the composite meter reads its frozen input `Q`s across +partitions without locks (§3.6). + +## 4. Additional Context + +### 4.1 Model Formulas (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-model-formulas-mm` + +The catalog `modelKind` enum and formulas — the pricing §17.2 mapping, shared SoR (SEAMS M1): + +| `modelKind` | Formula | Notes | +|-------------|---------|-------| +| `flat` | `unitPrice × Q`, or a fixed amount per period (recurring) | no thresholds evaluated | +| `per_unit` | `unitPrice × quantity` from frozen `quantitySource ∈ {subscription_seat_count, manual}` | **never** metered `Q`; p1 launch (SEAMS M2) | +| `graduated` | marginal band rate per unit within each open-top band | single-band case numerically = volume A; distinguished by configured kind | +| `volume` | one band rate × **all** units by total `Q` in the window | **Variant A only**; Variant B (per-tier block fee) is **not authorable** (pricing D Q3, SEAMS M4) | +| `package` | `ceil(usedQ / packageSize) × packagePrice` over the window | partial block rounds **up**; p2 launch (SEAMS M3) | + +- `hybrid` and `committed` are **compositions**, not kinds: hybrid = two lines (recurring + usage) under one `planId`, independently evaluated per their period boundaries; a hybrid "minimum commitment" is committed-usage (pool + overage, step 6) — **never** conflated with a period floor (slice [`09`](./09-period-plan-change.md)). Attachment points (commitment/floor to the usage line unless plan-level; coupon per `applyScope`, `line_total` split back pro-rata deterministically — executed by slice [`06`](./06-coupons.md)) are **frozen in `pricingSnapshotRef`**. +- `graduated`/`volume`/`package` are usage-only (D-18); bands are open-top (D-17); launch aggregation is `sum` only (SEAMS M10). +- **Band boundary rule** (money-affecting, adopted): thresholds are half-open `[lower, upper)` — a quantity exactly at a boundary falls in the **upper** band, identically for graduated marginal placement and volume-A band selection ([`../PRD.md`](../PRD.md) §1.4 Tier aggregation window). +- A free-tier allowance in current scope is expressed as a per-`(meter, dimensionKey)` **$0 band** ([`../PRD.md`](../PRD.md) §15); a cross-account allowance is a Follow-on aggregate. + +### 4.2 Meter Mapping and Dimensional Lines (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-dimensional-mapping-mm` + +- The mapping unit → `(meter, dimensionKey)` MUST be **injective per plan revision**; violation is a fail-closed configuration error, never a merged line. +- Each distinct `(meter, dimensionKey)` resolves to **its own** charge line and price; a plan declaring no dimensions prices as the single empty-tuple line. +- Empty/partial dimension values on a dimension-declaring plan route to an explicitly **published** default/catch-all line, else fail closed (reject/quarantine) — never guess. +- Ownership split (SEAMS M6, PRD §6.7): catalog **declares** (persists `dimension_key` structurally now); this slice **freezes** the declared set in `pricingSnapshotRef`; Rating passes values through; OSS metering **emits** values — the external critical path (§17.3). Until emission lands, `dimensionKey` is the empty tuple and per-combination meters are the only workaround (cardinality risk, §16). **Open:** the cross-doc launch-posture wording is seam M6 (declaration+freeze in scope now; value-pricing OSS-gated) — tracked in [`../SEAMS.md`](../SEAMS.md), pending joint wording, not a math change. + +### 4.3 Tier Aggregation Window and `Q` (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-tier-window-mm` + +- `tierAggregationWindow ∈ {calendar_month, invoice_period, subscription_lifetime, per_event}` governs when the tier counter resets; the active value is recorded in evaluation metadata **and** frozen in `pricingSnapshotRef` ([`../PRD.md`](../PRD.md) §6.5). +- Boundaries: `calendar_month` in UTC; `invoice_period` anchored per the frozen catalog `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}` with the D-20 no-drift clamp (31→28→31, anchor day preserved) — the anchor authority is slice [`09`](./09-period-plan-change.md)'s adopted enum (SEAMS P2); this slice consumes the resolved boundaries. +- The counter is the window-aggregated `Q` for **`(subscription, meter, dimensionKey, window)`** (SEAMS M7 — the superset key: per-subscription reset scope + per-dimension counters); Rating is the single writer per this key; Rating never aggregates and never mutates the counter. For `tierAggregationWindow ≠ per_event`, tier/volume/package math evaluates over `Q`; for `per_event`, the unit is the event (01 §4.2). +- **Aggregation derivation (D-44 / T-D-17)**: `Q`'s derivation is the row's frozen `aggregationFunction ∈ {sum (default), peak, time_weighted}`. For `sum`, `Q` is the plain sum of normalized measures. For non-`sum` the meter is **level-shaped** (gauge samples in the level unit): the window is cut into `aggregationGranularity ∈ {hour (default), day}` granules; each granule folds deterministically — `peak` = max sample in the granule, `time_weighted` = step-integral of the level over the granule (`hold_last` bounded by the declared `maxHold`; beyond it the level reads 0 + operator signal, never a guess) — and **`Q` = Σ granule folds**, so `Q` stays **additive** and every rule in this section (band math, supersession continuity, `bandOffsetQ` slice math, package cumulative-ceil) applies unchanged. The billable unit is level·granule-hours (GB·h, cloudlet·h) — the SKU-declared unit, distinct from the sample's level unit. A late/corrected sample re-folds **only its granule** → a `Q` delta under the standard re-materialization (slice [`13`](./13-q-store-attribution.md) §4.4). Non-`sum` does not co-occur with composite meters at launch ([`../PRD.md`](../PRD.md) `fr-level-aggregation`). +- **Intra-window boundary continuity (adopted, money-affecting — T-D-12)**: when a slice-[`09`](./09-period-plan-change.md) split point (mid-cycle window activation/supersession, plan change, phase conversion) falls **inside** an open aggregation window, the window remains the counter scope but **each sub-window slice is its own evaluation unit** ([`01-foundation.md`](./01-foundation.md) §4.2): a slice prices only its attributed `Q_slice` under its own single pinned snapshot, and the accumulated prior-slice quantity arrives as the explicit frozen input **`bandOffsetQ`**. Adopted verbatim from pricing: supersession does **NOT** reset an in-window counter — the new row's bands apply to the continued `Q` ([`../../../pricing/docs/design/03-price-structure.md`](../../../pricing/docs/design/03-price-structure.md), joint fixture `inst-tb-window-continuity`). Per-kind slice math: + - `graduated` — `Q_slice` places marginally into the slice row's bands over `[bandOffsetQ, bandOffsetQ + Q_slice)`; + - `volume` — the band is selected by the window-cumulative `Q` (`bandOffsetQ + Q_slice` as of the evaluation); the slice bills `Q_slice ×` the selected band rate of **its own** frozen row; cumulative growth that later crosses a band boundary re-resolves the earlier slices as deltas, each from its own pin (slice [`08`](./08-retroactivity-corrections.md) cascade); + - `package` — blocks are counted **once over the window** by cumulative ceil-diff: the slice bills `ceil((bandOffsetQ + Q_slice)/packageSize) − ceil(bandOffsetQ/packageSize)` blocks at its own frozen `packagePrice` — a straddling block belongs to the slice that opened it, and the window total is exactly `ceil(windowQ/packageSize)`. + + Boundary kinds: window-activation/supersession and phase-conversion boundaries **always carry** (`bandOffsetQ` = accumulated prior-slice `Q`; continuity is not configurable); only a **plan-change** boundary consults the snapshot-frozen carry-vs-reset flag routed by slice [`09`](./09-period-plan-change.md) (`reset` ⇒ `bandOffsetQ = 0`). Rating (single-writer) materializes the per-slice attribution and `bandOffsetQ` from event-time attribution; a change to an earlier slice's `Q` shifts later slices' `bandOffsetQ` and re-resolves them under the slice-08 cascade. +- **Reservation remainder re-band (T-D-13)**: the consumption-flavor matched quantity is excluded from `Q` (slice [`05`](./05-commitments-reservations.md) §4.2); this slice's band math re-runs over the on-demand remainder **from zero** as part of the steps-3–5 remainder re-run — band placement first, then the steps-4–5 overlays re-apply to the re-banded amount ([`04-overlays-precedence.md`](./04-overlays-precedence.md) §4.2). +- Correction-time counter decrement is executed by Rating under slice [`08`](./08-retroactivity-corrections.md) keys. + +### 4.4 Granularity Round-Up (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-granularity-mm` + +- `billingGranularity ∈ {per_second, per_minute, per_hour, per_day, whole-unit}`; round-up applies to the **aggregated/merged measure** of the evaluation unit, **never per raw `UsageRecord`** — twelve 5-minute samples at `per_hour` bill **1 hour**, not 12. +- Continuous-duration meters: contiguous usage is merged into a session/window measure first (Rating owns the merge), then rounded up **once**; discrete/`per_event` meters: the unit is the event; windowed models: round-up applies to the window measure **before** tier placement. +- The applied `billingGranularity` is recorded in evaluation metadata; amounts continue at full intermediate precision (01 §4.4). +- A per-resource `minimumCharge` MAY be configured to bound ephemeral-churn over-charge — the churn policy itself is a PRD §15 open (Product + Finance); this slice only honors a configured value. + +## 5. Traceability + +- **PRD**: §6.2 (all seven model FRs incl. hybrid/committed composition status), §6.3 `fr-meter-mapping-granularity`, §6.5 `fr-tier-aggregation-window` + `fr-billing-granularity`, §6.7 (dimensional, dimension-population, composite), §17.1 step 3, §17.2 (kind→formula SoR), §17.3 (cloud phasing). +- **Seams**: M1 (enum/mapping), M2 (per_unit launch-blocking), M3 (package), M4 (Variant B deleted), M5 (composite in launch), M6 (**open** — dimensional wording), M7 (counter key), M10 (re-scoped 2026-07-16: `aggregationFunction {sum, peak, time_weighted}` in launch — pricing D-44 / T-D-17), M11 (D-17/D-18 guarantees) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-04 (M7 key), T-D-05 (model set + launch scope), T-D-12 (intra-window boundary continuity / `bandOffsetQ`), T-D-13 (steps-3–5 remainder re-run) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md) (line key vs window key boundary). +- **Related slices**: [`01-foundation.md`](./01-foundation.md) (unit, precision, digest), [`02-selection-eligibility.md`](./02-selection-eligibility.md) (selected row in), [`04-overlays-precedence.md`](./04-overlays-precedence.md) (stack on the model output), [`05-commitments-reservations.md`](./05-commitments-reservations.md) (committed composition), [`06-coupons.md`](./06-coupons.md) (hybrid `applyScope` split execution), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (counter decrement), [`09-period-plan-change.md`](./09-period-plan-change.md) (anchor authority, floor/cap). diff --git a/gears/bss/rating/docs/design/04-overlays-precedence.md b/gears/bss/rating/docs/design/04-overlays-precedence.md new file mode 100644 index 000000000..9a0907e80 --- /dev/null +++ b/gears/bss/rating/docs/design/04-overlays-precedence.md @@ -0,0 +1,313 @@ + + + +# DESIGN — Overlays & Precedence (Slice 4) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-overlays-precedence` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Scope → Tenant-Axis Mapping (normative)](#41-scope--tenant-axis-mapping-normative) + - [4.2 Stacking and the Total Order (normative)](#42-stacking-and-the-total-order-normative) + - [4.3 Contract Overlay Precedence (normative)](#43-contract-overlay-precedence-normative) + - [4.4 Bounded Composition — Anti-Drift Cap (normative)](#44-bounded-composition--anti-drift-cap-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +Overlays & Precedence is the **steps 4–5 evaluator**: it resolves which `PriceOverlays` +survive the scope filter for the evaluation context, applies **all** survivors as a sequential +**stack** in one deterministic total order, then applies the customer/contract overlay on top — +`Contract > Partner price overlays > Catalog base`. The stacking decision is the load-bearing seam +resolution of this slice (SEAMS **O3, resolved 2026-07-10**): step 4 **stacks** — partner + +brand + region overlays are legitimately cumulative; the pricing **class-specificity order** +`customerGroup > partner > orgTier > brand > region > global` (pricing `design/09`, adopted +verbatim — O1, including the `customerGroup` class Rating was missing — O2) is the +**cross-class tie-break inside the stack**, never a winner-take-all filter. + +Determinism comes from three locks: the scope→tenant-axis mapping is a fixed table (no +heuristic tenant matching — `resourceTenantId` alone never matches a partner list); the stack +order is total (`precedence` → class order → `priceOverlayId`); and the cumulative result is +bounded by the **anti-drift cap** (`maxCumulativeMarkup`) — clamp-and-record or hard-fail, +never silent unbounded compounding. The applied overlay set is a Rating-written segment of +`pricingSnapshotRef` (SEAMS S1), and per-layer lineage rides the outcome for audit. Overlay +*authoring* and publish-time precedence validation live in the pricing gear; contract-overlay +authoring lives in Contracts; this slice only evaluates ([`../PRD.md`](../PRD.md) §6.4). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-priceoverlay-scope-mapping` | `ScopeFilter` implements the fixed scope→axis table (§4.1): `global` always; `customerGroup` by the payer's BSS-resolved group at `t`; `partner`/`orgTier` by `sellerTenantId`; `brand` by Plan/SKU `brandId` at `t`; `region` by the usage/price-row region key; `resourceTenantId` never qualifies a partner/orgTier list alone. | +| `cpt-cf-bss-rating-fr-overlay-stacking` | `OverlayStacker` applies **all** survivors sequentially in the total order: ascending `precedence` → class-specificity order for cross-class ties → ascending `priceOverlayId` within class (§4.2); the class order breaks ties, it does not pick a single winner (O3). | +| `cpt-cf-bss-rating-fr-customer-contract-overlay` | `ContractOverlayApplier` applies contract/account overrides **after** step 4; contract terms outrank partner lists; dimension-integrity and approval bounds are publish-side guarantees this evaluator relies on (§4.3). | +| `cpt-cf-bss-rating-fr-bounded-composition-cap` | `CompositionCapGuard` tracks the cumulative markup/discount across the partner → reseller → customer chain against `maxCumulativeMarkup`: clamp-and-record, or fail-closed when the cap is hard (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | `ScopeFilter` + `OverlayStacker` | Filtering and stacking are in-memory over pinned `PriceOverlay` pages; the stack length is bounded by the published overlay set; no I/O in steps 4–5 | Load test; targets provisional (NFR workshop) | +| `cpt-cf-bss-rating-nfr-audit-segregation` | Lineage + publish-side controls | Per-layer pre/post amounts + applied ids ride the outcome (01 §4.4 lineage); customer-layer changes cannot weaken audit controls — Contract workflow + optional Finance approval are upstream obligations this slice records, never relaxes | Audit fixture | +| `cpt-cf-bss-rating-nfr-resilience` | `CompositionCapGuard` + runtime safety net | Publish-time validation rejects equal-precedence overlap within a class; the runtime total order still yields a single deterministic result if a defective catalog slips through (never an arbitrary pick) | Chaos test + joint fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | The `priceOverlay` axis of the 8-axis key carries the base row; overlay lists are step-4 stack material — the split this slice implements (SEAMS K1/O1–O3). | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | Key definition SoR; `priceOverlay` is column 4. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-ovl` + +```text +Steps 4–5 evaluator (this slice) ScopeFilter · OverlayStacker · ContractOverlayApplier · + │ (registers into §17.1 slots 4–5) CompositionCapGuard · OverlayLineageRecorder + ▼ +Foundation mechanisms (01) EvaluationPipeline · SnapshotComposer (overlay segment) · + MetadataRecorder (per-layer lineage) + │ + ▼ +Frozen inputs pinned PriceOverlay set (pricing) · contract overlay terms + (Contracts) · payer customerGroup at t (BSS claims, frozen in ctx) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The steps 4–5 evaluator: scope filtering, stacking, contract overlay, cap guard | Rust module in the `rating` gear (rating-core crate) | +| Domain | Scope classes, stack order key, overlay lineage, cap policy shapes | Rust; GTS + Rust domain structs | +| Infrastructure | **None owned** — overlay definitions arrive pinned; contract terms arrive frozen | In-process (01 §3.7) | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Stack all survivors; tie-break, don't exclude + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-stack-not-winner-ovl` + +Step 4 applies **every** scope-matching survivor — cumulative partner + brand + region economics +are the intended commercial behavior (SEAMS O3). The class-specificity order resolves *ordering +ambiguity between classes*; treating it as overlay exclusivity (most-specific-wins over the +whole stack) is the misreading this principle forbids. + +#### One total order, no arbitrary picks + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-total-order-ovl` + +The stack order is total and deterministic: ascending `precedence`, then the class order for +cross-class ties, then ascending `priceOverlayId` within a class as the final stable tie-break. +Every evaluation of the same frozen context stacks in byte-identical order (01 §4.2). + +#### Axes are typed, not inferred + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-typed-axes-ovl` + +Scope matching uses exactly the mapped tenant axis per §4.1 — a list never matches "because the +tenant looks related". `resourceTenantId` (usage tenancy) alone never qualifies partner/orgTier +lists; `payerTenantId`/`accountId` act only in step 5. Cross-tenant price leakage is the failure +this principle prevents ([`../PRD.md`](../PRD.md) §6.3). + +### 2.2 Constraints + +#### Publish-side validation is relied on, not re-run + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-publish-side-guarantees-ovl` + +Equal `precedence` among lists with overlapping scope **within one class** is rejected at +publish (fail-closed, pricing-side pipeline — validator registration is slice +[`10`](./10-governance-asc606.md)'s G1 material); contract overlays introducing metering +dimensions absent from the published Plan/SKU revision are rejected at contract publish. The +runtime total order is a **safety net**, not a substitute: a defective catalog still yields a +single deterministic result, flagged in metadata. + +#### The overlay segment is sealed + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-overlay-segment-ovl` + +The resolved overlay / `priceOverlay` ids are a **Rating-written segment** of +`pricingSnapshotRef` at evaluation (SEAMS S1, 01 §4.3); re-resolution replays the same overlay +set from the pin — never a live re-filter against the current catalog (SEAMS W2). + +#### Caps clamp or fail — never silently compound + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-cap-modes-ovl` + +A stacked result beyond `maxCumulativeMarkup` clamps to the cap and records the clamp, or fails +closed when the cap is marked hard; a **material multi-link chain without a configured cap is a +publish-time fail** (or a Finance-set default applies). Only the default cap value and the +clamp-vs-hard-fail mode remain open ([`../PRD.md`](../PRD.md) §15). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-ovl` + +All value objects; overlay definitions are frozen catalog content, contract terms frozen +Contracts content. + +- **`ScopeClass`** — the ordered class set `customerGroup > partner > orgTier > brand > region > global` (the order *is* the domain fact — O1/O2). +- **`OverlayCandidate`** — a pinned `PriceOverlay` with its scope, class, `precedence`, and `priceOverlayId`. +- **`SurvivorStack`** — the scope-filtered candidates in the total order (§4.2); retains per-candidate filter provenance for diagnostics. +- **`StackOrderKey`** — `(precedence asc, ScopeClass order, priceOverlayId asc)` — the comparator as a value. +- **`ContractOverlay`** — the frozen contract/account override terms (incl. negotiated reserved rates — consumed at step 6 via slice [`05`](./05-commitments-reservations.md), SEAMS M9). +- **`CapPolicy`** — `maxCumulativeMarkup` + mode (`clamp` \| `hard`); absence on a material multi-link chain is a publish-time condition, not a runtime branch. +- **`OverlayLineage`** — per-layer applied id + pre/post amounts, the clamp record if any; feeds `MetadataRecorder` and the snapshot overlay segment. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-overlays-precedence-ovl` + +The steps 4–5 evaluator, registered into the fixed slots (01 §3.2): + +- **`ScopeFilter`** — applies the §4.1 mapping table against the frozen context axes; emits the survivor set with per-list match provenance. +- **`OverlayStacker`** — orders survivors by `StackOrderKey` and applies them sequentially over the step-3 model output; flags (in metadata) any within-class equal-precedence overlap that publish validation should have rejected. +- **`ContractOverlayApplier`** — step 5: applies `ContractOverlay` after the stack; `Contract > Partner > Base`; relies on publish-side dimension-integrity and approval bounds (§4.3). +- **`CompositionCapGuard`** — accumulates the chain delta across steps 4–5 and enforces `CapPolicy` (§4.4). +- **`OverlayLineageRecorder`** — writes `OverlayLineage` into evaluation metadata and hands the resolved overlay/`priceOverlay` ids to the `SnapshotComposer` (S1 segment). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-stack-overlays-ovl` + +The **steps 4–5 contract** (internal; pipeline-invoked): `apply_overlays(ModelLineOutcome, +frozen PriceOverlay set, ContractOverlay, ctx) → OverlaidLineOutcome | OverlayProblem`. +Deterministic over the frozen tuple. Problems (this slice's rows in the Design-set taxonomy): +`cap_exceeded_hard` (hard cap breached), `missing_cap_material_chain` (defensive; normally a +publish-time fail), `contract_dimension_violation` (defensive; publish-side guarantee broken) +— all fail-closed; the clamp path is *not* a problem, it is a recorded outcome. + +### 3.4 Internal Dependencies + +Upstream: [`01-foundation.md`](./01-foundation.md) (pipeline, snapshot composer, lineage +recorder); [`02-selection-eligibility.md`](./02-selection-eligibility.md) (the base row and its +`priceOverlay` axis); [`03-metering-models.md`](./03-metering-models.md) (the model output the +stack applies to). Downstream: [`05-commitments-reservations.md`](./05-commitments-reservations.md) +(step 6 runs on the overlaid amount; negotiated reserved rates arrive via the contract overlay), +[`06-coupons.md`](./06-coupons.md) (coupons apply post-commitment), and the `EmissionGuard` +non-negative check after steps 4–7 (01 §4.4). + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing (Product Catalog) | the pinned `PriceOverlay` set: scope, class, `precedence`, `priceOverlayId`; publish-time precedence validation | [`11-consumer-contracts.md`](./11-consumer-contracts.md); SEAMS O1–O3 | +| Contracts & Agreements | frozen contract/account overlay terms, entitlement/approval bounds, negotiated reserved rates | PRD §6.4 step 5; SEAMS M9 | +| OSS/AMS (tenant identity) | the payer's BSS-resolved `customerGroup` at `t` (authenticated caller claims, frozen into the context) | PRD §6.3 scope mapping; SEAMS O2 | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-stack-overlays-ovl` + +**Stack overlays** (steps 4–5 of `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. `ScopeFilter`: evaluate each pinned `PriceOverlay` against the §4.1 axis table; collect survivors. +2. `OverlayStacker`: order by `StackOrderKey`; apply **all** survivors sequentially over the step-3 output; record per-layer lineage. +3. `ContractOverlayApplier`: apply the contract/account overlay on the stacked result (`Contract > Partner > Base`). +4. `CompositionCapGuard`: check the cumulative chain delta against `CapPolicy` — clamp-and-record, or fail closed on a hard cap (§4.4). +5. `OverlayLineageRecorder`: lineage → metadata; resolved overlay/`priceOverlay` ids → the S1 snapshot segment; hand to step 6. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-cap-clamp-ovl` + +**Cap clamp**: stacked delta exceeds a `clamp`-mode cap → the amount is clamped **to** the cap, +the clamp is recorded in metadata (pre-clamp amount preserved in lineage), evaluation continues; +under a `hard` cap the same condition fails closed instead ([`../PRD.md`](../PRD.md) §6.4). + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-ovl` + +**None owned.** `PriceOverlay` definitions and precedence live in the pricing gear; contract +overlays live in Contracts; the applied set lives in the sealed snapshot segment and outcome +lineage (01 §3.7). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-ovl` + +Nothing beyond the Foundation posture (01 §3.8); stacking is per-line, in-memory, and +partition-local. + +## 4. Additional Context + +### 4.1 Scope → Tenant-Axis Mapping (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-scope-mapping-ovl` + +Adopted verbatim from [`../PRD.md`](../PRD.md) §17.1 (scope mapping table), including the +`customerGroup` class (SEAMS O2): + +| `PriceOverlay.scope` | MUST match (evaluation context) | +|-------------------|--------------------------------| +| `global` | always eligible (subject to plan/SKU applicability) | +| `customerGroup` | payer's BSS-resolved customer group at `t` (authenticated caller claims); most-specific class | +| `partner`, `orgTier` | `sellerTenantId` (channel/reseller that sold the subscription) | +| `brand` | Plan/SKU `brandId` at `t` | +| `region` | usage or price-row `region` key | + +Axes **not** used as scope filters: `resourceTenantId` (usage tenancy — MUST NOT alone match +partner/orgTier rows); `payerTenantId` / `accountId` (contract/account overlays, step 5); +`sellerTenantId` is consumed only by `scope(partner|orgTier)`. + +### 4.2 Stacking and the Total Order (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-stacking-order-ovl` + +- Step 4 applies **all** scope-matching survivors as a sequential stack (SEAMS O3, resolved: stack-all; rejected: single-winner). +- Total order: ascending `precedence` (lower first) → for **cross-class** ties (precedence is unique only *within* a class) the class-specificity order `customerGroup > partner > orgTier > brand > region > global` (pricing `design/09`, adopted verbatim — O1) → ascending `priceOverlayId` as the final **within-class** stable tie-break. +- Equal `precedence` among lists with overlapping scope within one class MUST be rejected at publish (fail-closed); the runtime order is the safety net that still produces a single deterministic result. +- **Single-layer application semantics**: each survivor applies its **published adjustment** to the running post-model line amount — sequential compounding, layer *n+1* sees layer *n*'s output. A percentage adjustment (basis points) scales the amount; a per-currency **absolute amount adjustment** (pricing D-08, `pricing_price_overlay_amount`) applies its published value for the line's price currency. Adjustments are **line-level** — the published overlay model carries no per-band adjustment, so banded lines are adjusted on the post-band total. Publish-side guarantee relied on: every absolute adjustment covers the line's price currency (`ADJUSTMENT_CURRENCY_NOT_COVERED` rejected at pricing publish); an uncovered currency observed at runtime is a defensive fail-closed. +- **Recompute rule (T-D-13)**: when step 6's reservation split re-bands the on-demand remainder ([`05-commitments-reservations.md`](./05-commitments-reservations.md) §4.2), steps 4–5 **re-apply as part of the steps-3–5 remainder re-run**: the same frozen survivor set, total order, and adjustment values compound over the re-banded remainder amount — only the base amount changes. The pre-split full-`Q` pass is superseded (both passes recorded in lineage); the reserved-rate portion is **not** re-overlaid — its rate is already the post-step-5 effective value (05 §4.3); the `CompositionCapGuard` evaluates on the authoritative re-run amounts. +- **Open (O3-confirm):** pricing to confirm its "most-specific-wins" wording reads as *class selection + tie-break*, not overlay exclusivity — expected already true; tracked in [`../SEAMS.md`](../SEAMS.md) / [`../DECISIONS.md`](../DECISIONS.md) (T-D-02). + +### 4.3 Contract Overlay Precedence (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-contract-overlay-ovl` + +- Step 5 applies contract/account-level overrides **after** the step-4 stack; contract terms outrank partner lists: `Contract > Partner price overlays > Catalog base`. +- Overrides MUST NOT introduce metering dimensions absent from the published Plan/SKU revision — contract publish validation rejects fail-closed; the evaluator relies on the guarantee (defensive `contract_dimension_violation` problem if it is ever observed broken). +- Customer-layer changes MUST NOT silently weaken audit controls (Contract workflow + optional Finance approval — upstream obligations; lineage records what applied). +- Negotiated reserved-instance rates ride this overlay (Contracts SoR) and are consumed by step 6 — the reserved-rate two-source split is slice [`05`](./05-commitments-reservations.md)'s M9 rule. + +### 4.4 Bounded Composition — Anti-Drift Cap (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-anti-drift-cap-ovl` + +- The cumulative markup/discount across the full partner → reseller → customer chain is bounded by `maxCumulativeMarkup`. +- Exceeding a `clamp` cap ⇒ clamp to the cap + record the clamp in metadata (pre-clamp amount in lineage); exceeding a `hard` cap ⇒ fail closed. Silent unbounded compounding is forbidden in every mode. +- A **material multi-link chain** with no configured cap MUST fail closed **at publish** (or a Finance-set default applies); a publish-time warning is acceptable only for a single-link, non-material overlay. +- **Open (PRD §15):** the default cap value and the clamp-vs-hard-fail mode are a Program/Finance workshop decision; the step-4 normative behavior above does not depend on it. + +## 5. Traceability + +- **PRD**: §6.3 `fr-priceoverlay-scope-mapping`; §6.4 `fr-overlay-stacking`, `fr-customer-contract-overlay`, `fr-bounded-composition-cap`; §17.1 steps 4–5 + scope-mapping table. +- **Seams**: O1 (class order as cross-class tie-break), O2 (`customerGroup` class), O3 (**stack-all resolved; pricing wording confirmation open**) — [`../SEAMS.md`](../SEAMS.md); S1 (overlay segment), M9 (negotiated rate pass-through), W2 (replay from the pin). +- **Decisions**: T-D-02 — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md) (base-row `priceOverlay` axis vs overlay stack split). +- **Related slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, snapshot segment, lineage, non-negative guard), [`02-selection-eligibility.md`](./02-selection-eligibility.md) (base row in), [`03-metering-models.md`](./03-metering-models.md) (model output in), [`05-commitments-reservations.md`](./05-commitments-reservations.md) (step 6 on the overlaid amount; M9), [`06-coupons.md`](./06-coupons.md) (step 7 after), [`10-governance-asc606.md`](./10-governance-asc606.md) (publish-time validator registration, G1). diff --git a/gears/bss/rating/docs/design/05-commitments-reservations.md b/gears/bss/rating/docs/design/05-commitments-reservations.md new file mode 100644 index 000000000..141acd826 --- /dev/null +++ b/gears/bss/rating/docs/design/05-commitments-reservations.md @@ -0,0 +1,324 @@ + + + +# DESIGN — Commitments & Reservations (Slice 5) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-commitments-reservations` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Commitment-Pool Waterfall (normative)](#41-commitment-pool-waterfall-normative) + - [4.2 Reservation Flavors and Pool Precedence (normative)](#42-reservation-flavors-and-pool-precedence-normative) + - [4.3 Reserved-Rate Two-Source Rule (normative)](#43-reserved-rate-two-source-rule-normative) + - [4.4 Commitment Pool vs Prepaid Credit Grant (normative)](#44-commitment-pool-vs-prepaid-credit-grant-normative) + - [4.5 Obligations, Reversals, and Period Boundaries (normative)](#45-obligations-reversals-and-period-boundaries-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **step-6 evaluator** of the §17.1 order: the commercial-composition layer between the +overlay/contract steps (4–5) and coupons (step 7), registered into the Foundation pipeline's fixed slot +([`01-foundation.md`](./01-foundation.md) §3.2). Per **T-D-05**, `committed` is **not a modelKind**: +commitment and reservation *wrap* the model output of steps 2–5 — they split the evaluation unit into +reserved / in-commit / overage portions priced at already-resolved rates, never a formula of their own +([`../DECISIONS.md`](../DECISIONS.md), [`../PRD.md`](../PRD.md) §6.2). + +Ownership is deliberately thin: **Contracts is the SoR for `commitmentPools[]` balances**, the reservation +entitlement lifecycle is OSS/Contracts', self-service reserved rates are the pricing gear's snapshot +attributes. Rating evaluates a **frozen pool/reservation snapshot** carried in the context, records the +drawdown effect, and **surfaces** the structured `TrueUpObligation` — it never stores a balance, posts a +charge, or mutates contract state ([`../PRD.md`](../PRD.md) §6.6, [`../SEAMS.md`](../SEAMS.md) ownership +matrix). The slice owns two seams: **M8** (pool ≠ prepaid credit grant, §4.4) and **M9** (§4.3). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-commitment-drawdown` | Deterministic waterfall over the ordered `commitmentPools[]` snapshot (§4.1): each pool absorbs quantity/spend up to its remaining frozen balance; residual is overage/on-demand; single pool is the default special case; always step 6, no reordering. | +| `cpt-cf-bss-rating-fr-committed-usage` | The in-commit / overage split prices at the distinct frozen rates; period true-up per contract clause is surfaced as a structured `TrueUpObligation` (§4.5) — never an implicit posted charge; reversal (pool refill) is delegated to slice 08. | +| `cpt-cf-bss-rating-fr-reservation-consumption-flavor` | The `ReservationMatcher` splits matched vs remainder **before** pools (§4.2): matched usage prices at the reserved rate (source per §4.3), the remainder at the steps-2–5 on-demand rates with its tier counter banded from zero; matched quantity never draws pools. | +| `cpt-cf-bss-rating-fr-capacity-charge` | Capacity flavor emits `capacityCharge = reservedRate × reservedQuantity` regardless of measured usage (§4.2); never reduced by absent usage, never draws `commitmentPools[]`; flavor/quantity/rate frozen in the snapshot. | +| `cpt-cf-bss-rating-fr-hybrid-pricing` | On a hybrid plan the commitment attaches to the **usage line** unless the plan marks it plan-level; the attachment configuration is consumed frozen from `pricingSnapshotRef` — this slice never decides attachment. | +| `cpt-cf-bss-rating-fr-evaluation-order` | A registered step evaluator in the compiled §17.1 slot 6; intra-step order (reservation → waterfall → overage) is equally fixed (§3.6). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | Step-6 evaluator | Pure in-process arithmetic over the context-frozen pool/reservation snapshot; no Contracts/OSS call on the hot path | Load test; targets provisional (NFR workshop, [`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partitioning contract | Per-unit evaluation over balances frozen into the context; no live balance read, no cross-partition pool lock (cross-unit balance sequencing is the SoR's — §4.1 open) | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Fail-closed guards | Missing/torn pool snapshot, a `reservationMatch` without a resolvable rate source, or an unknown flavor fails closed — never a guessed drawdown | Chaos/retry + joint fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | The rates step 6 composes over — base row, overlay stack, and the self-service `reservedRate` attribute riding the selected usage row — all resolve on the adopted 8-axis key; this slice adds no selection axis. | + +No slice-local ADR: step-6 semantics are PRD-normative (§6.6, §17.1) under **T-D-05** / **T-D-08**. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-cmt` + +```text +Step-6 evaluator (this slice) ReservationMatcher → PoolWaterfall → TrueUpAssembler + │ (registers into the fixed §17.1 slot 6) + ▼ +Evaluation pipeline (01) EvaluationPipeline · SnapshotComposer · MetadataRecorder · + │ EmissionGuard (non-negative, after steps 4–7) + ▼ +Frozen inputs (external SoRs) commitmentPools[] snapshot (Contracts) · reservationMatch (ctx; + OSS/Contracts entitlement) · reservedRate/flavor (pinned catalog + row, pricing) · negotiated RI rate (step-5 overlay) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The step-6 evaluator and its intra-step order; obligation assembly | Rust module in the `rating` gear (rating-core crate), registered into the Foundation pipeline | +| Domain | Pool/reservation snapshot shapes, effect and obligation value objects (§3.1) | Rust; GTS + Rust domain structs | +| Infrastructure | **None** — no store, no cache; all inputs arrive frozen in the `EvaluationContext` | n/a | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Composition, not a model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-composition-cmt` + +Commitment and reservation are commercial **compositions** over the steps-2–5 output (T-D-05): they +partition the evaluation unit and price the partitions at rates resolved upstream — no new `modelKind`, +no band math beyond the normative tier-counter exclusion (§4.2). + +#### Frozen balances, thin evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-frozen-balances-cmt` + +Every balance, draw order, rollover policy, flavor, and reserved quantity arrives frozen in the +context; Rating never reads, locks, or writes a live Contracts balance. One frozen tuple ⇒ a +byte-identical drawdown on replay ([`01-foundation.md`](./01-foundation.md) §4.2). + +#### Surface, never post + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-surface-not-post-cmt` + +Step 6 produces *effects and obligations*, not postings: the `TrueUpObligation` is a structured +value Billing executes ([`../PRD.md`](../PRD.md) §6.2, §9.2); the drawdown effect is outcome +lineage, not a balance mutation. + +### 2.2 Constraints + +#### Fixed slot, fixed intra-step order + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-fixed-slot-cmt` + +Commitment is **always** evaluated at step 6 ([`../PRD.md`](../PRD.md) §6.6, §17.1 — "no +reordering"); within the step, reservation precedes pools and overage is the residual. Neither +order has a configuration surface. + +#### Reversal math is slice 08's + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-reversal-boundary-cmt` + +Correcting/negative usage **refills the drawn-down pool and emits compensating deltas without +driving a line negative** ([`../PRD.md`](../PRD.md) §6.2) — this slice owns the *shape* of the +drawdown effect that makes the refill computable; the reversal math itself runs in +[`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) under the Foundation +correction keys ([`01-foundation.md`](./01-foundation.md) §4.2). + +#### Launch posture: single pool first + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-launch-posture-cmt` + +The waterfall contract (ordered pools, snapshot carriage) is defined and frozen **now**; launch +exercises the single-pool default special case. Multi-pool drawdown and per-pool rollover +(burn-vs-carry) behavior are additive Follow-on over the same ordered `commitmentPools[]` +([`../PRD.md`](../PRD.md) §17.4) — the rollover *field* is frozen from day one. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-cmt` + +- **`CommitmentPoolSnapshot`** — frozen per-pool input: pool id, contract ref, pool unit (quantity vs spend), remaining balance, declared draw order, rollover policy; the ordered set plus the reserved-vs-pool split rides the snapshot ([`../PRD.md`](../PRD.md) §6.6). +- **`ReservationMatch`** — optional context input ([`01-foundation.md`](./01-foundation.md) §3.1): match id, flavor (`consumption | capacity`), reserved-rate source (§4.3), `reservedQuantity`, coverage. +- **`CommitmentEffect`** — the recorded outcome lineage: per-pool draw amounts in declared order, in-commit vs overage quantity/spend split, applied rates. +- **`ReservationEffect`** — matched quantity priced at the reserved rate, the `capacityCharge` (capacity flavor), and the exclusions applied (pool drawdown, tier counter). +- **`TrueUpObligation`** — `(amount, period, contract ref)`; **shape owned by this slice**, emission envelope by the Foundation ([`01-foundation.md`](./01-foundation.md) §3.1); executed by Billing, never posted here. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-commitment-evaluator-cmt` + +- **`Step6Evaluator`** — the registered evaluator; enforces the intra-step order and the fail-closed input guards; composes the three parts below. +- **`ReservationMatcher`** — flavor dispatch; computes the matched/remainder split (consumption) or the allocation charge (capacity); signals the matched-quantity exclusion to the tier counter (§4.2). +- **`PoolWaterfall`** — ordered drawdown over `CommitmentPoolSnapshot[]`; produces the in-commit/overage split and per-pool draws; a pool absorbs at most its remaining balance — the waterfall itself can never produce a negative component. +- **`TrueUpAssembler`** — evaluates the contract's period-end true-up clause over the period aggregate into a `TrueUpObligation` per the §4.5 flavor formulas, invoked on the period-driven unit ([`01-foundation.md`](./01-foundation.md) §4.2, T-D-15); effects and applied rates are recorded through the Foundation `MetadataRecorder`, frozen pool/reservation identifiers ride the composed ref via the `SnapshotComposer` (§4.1 open). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-step6-evaluator-cmt` + +The **step-evaluator contract** (in-process, registered into the pipeline): input = the +post-step-5 line state (resolved rates, bands, overlay lineage) + the frozen context +(`commitmentPools[]` snapshot, optional `reservationMatch`); output = `ReservationEffect` + +`CommitmentEffect` + obligations, applied to the line amount. Fail-closed problem values: torn or +missing pool snapshot fields; a `reservationMatch` with an unknown flavor or an unresolvable rate +source (§4.3); a reserved-vs-pool split absent when both constructs are present. No external API — +boundary contracts are owned by [`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Upstream: [`01-foundation.md`](./01-foundation.md) (slot registration, obligation envelope, +`MetadataRecorder`, `EmissionGuard`, determinism keys); +[`03-metering-models.md`](./03-metering-models.md) (the model formula pricing the on-demand +remainder; its band math consumes this slice's matched-quantity exclusion — §4.2); +[`04-overlays-precedence.md`](./04-overlays-precedence.md) (steps 4–5 deliver the post-overlay +rates and the negotiated RI rate as the step-5 contract overlay). Downstream: +[`06-coupons.md`](./06-coupons.md) (post-commitment amount); +[`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (reversal/refill); +[`09-period-plan-change.md`](./09-period-plan-change.md) (plan-change pool carry-vs-reset, period +wrappers). + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Contracts & Agreements | `commitmentPools[]` snapshot (pool set, balances, draw order, rollover policy), true-up clause, reserved-vs-pool split; negotiated RI rates via the step-5 overlay | [`../PRD.md`](../PRD.md) §9.2, §13; [`11-consumer-contracts.md`](./11-consumer-contracts.md) | +| Pricing (Product Catalog) | `reservedRate` + `reservationFlavor` as attributes **on the single usage row**, frozen in the pinned snapshot; reservation joint fixture gates publish | [`../../../pricing/docs/design/10-advanced-primitives.md`](../../../pricing/docs/design/10-advanced-primitives.md) §3 | +| OSS / Contracts (entitlement) | `reservationMatch` entitlement lifecycle/inventory — a **hard precondition** for reservation pricing | [`../PRD.md`](../PRD.md) §17.3 | +| Billing | Executes `TrueUpObligation`; Rating posts nothing | [`../PRD.md`](../PRD.md) §9.2 | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-step6-line-cmt` + +**Step 6 on one line** (within `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. Guard: verify pool-snapshot integrity and, if `reservationMatch` is present, a known flavor and a resolvable reserved-rate source (§4.3); otherwise fail closed. +2. Capacity flavor: emit `capacityCharge = reservedRate × reservedQuantity` — independent of measured usage, never reduced by its absence, never drawing pools. +3. Consumption flavor: split the evaluation unit into matched (priced at the reserved rate) and on-demand remainder; the matched quantity is excluded from pool drawdown **and** from the on-demand tier counter; the remainder re-runs steps 3–5 as a unit (§4.2, T-D-13). +4. Waterfall: the remaining on-demand quantity/spend draws the ordered pools, each up to its remaining frozen balance; in-commit billability follows each pool's frozen `poolType` (§4.1, T-D-14); the residual beyond all pools prices as overage/on-demand at the post-step-5 rates of the remainder re-run (banded lines) or the frozen flat rates. +5. Record: per-pool draws, the in-commit/overage split, and the reservation-match id go to metadata and the snapshot segment; the line then proceeds to step 7 and the `EmissionGuard` ([`01-foundation.md`](./01-foundation.md) §4.4). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-trueup-period-cmt` + +**Period true-up surfacing**: + +1. When the contract defines period-end true-up, the **period-driven unit** at the `AnchorPeriod` boundary (synthesized by Rating's period tick — [`01-foundation.md`](./01-foundation.md) §4.2, T-D-15) evaluates the clause over the frozen period aggregate of the governing window unit, per the §4.5 flavor formulas. +2. Emit `TrueUpObligation(amount, period, contractRef)` through the Foundation envelope — surfaced for Billing execution, never an implicit posted charge ([`../PRD.md`](../PRD.md) §6.2, §12 AC 5). +3. Corrections against a period with a surfaced obligation flow as deltas under the correction key (slice 08); the obligation itself is never mutated in place. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-cmt` + +**None owned.** No table, no cache: pool balances and true-up clauses are Contracts'; the +reservation entitlement inventory is OSS/Contracts'; reserved-rate attributes are the pricing +gear's read model; the drawdown effect and obligations persist only inside the rated outcome owned +by Rating ([`01-foundation.md`](./01-foundation.md) §3.7). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-cmt` + +A stateless registered evaluator in the `rating` gear (rating-core crate) — +identical topology to the Foundation ([`01-foundation.md`](./01-foundation.md) §3.8). Per-partition +evaluation; no cross-partition pool coordination on the hot path (§4.1 open covers cross-unit +balance sequencing). + +## 4. Additional Context + +### 4.1 Commitment-Pool Waterfall (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-waterfall-cmt` + +- Drawdown runs over the **ordered** `commitmentPools[]` in declared order: each pool absorbs quantity/spend up to its remaining frozen balance before the next; the residual beyond all pools is overage/on-demand; a single pool is the default special case ([`../PRD.md`](../PRD.md) §6.6, §17.1 step 6). +- In-commit and overage portions price at **distinct frozen rates**; both values arrive frozen (contract terms via the step-5 overlay / the pinned snapshot) — Rating consumes, never decides, the committed and overage rate values ([`../PRD.md`](../PRD.md) §6.2). +- **Pool flavor and billability (adopted 2026-07-11, T-D-14)**: every pool carries a frozen `poolType ∈ {prepaid_drawdown, committed_rate}` (Contracts-authored, carried in the `commitmentReservation` segment). `prepaid_drawdown` — the pool was sold and billed **upfront at sale** (the commitment sale itself is a Contracts/Billing concern, out of rating-core scope); in-commit consumption emits a **zero-due line** whose notional value (`quantity × frozen in-commit reference rate`) rides the lineage for revenue reporting; no pool-driven true-up; the unused balance at period end follows the pool's rollover policy (burn vs carry) — never a clawback line. `committed_rate` — in-commit consumption **bills in arrears** at the frozen in-commit rate; the period-end shortfall true-up follows the §4.5 formulas. Overage residual bills at the overage/on-demand rates in both flavors. +- Step 6 precedes FX (step 8): spend pools draw in **price currency**; quantity pools draw the step-3 normalized measure of the evaluation unit. A spend pool whose denomination differs from the line's price currency **fails closed** — no FX exists inside step 6 (conversion is step 8's); denomination coverage is a Contracts-side authoring obligation asserted at contract publish. +- **Tier-counter asymmetry (money-affecting)**: the in-commit quantity is **not** excluded from the tier counter `Q` — a commitment pool is a settlement instrument over the usage, not a capacity carve-out; band placement sees the full usage quantity. Only the **reservation** exclusion (§4.2) re-bands a remainder from zero. Where the contract defines distinct in-commit/overage rates, those flat rates price the split portions without band math. The joint fixture MUST assert this pool-vs-reservation asymmetry. +- **Snapshot carriage (resolved 2026-07-11, T-D-09)**: the frozen pool set, per-pool balances, draw order, rollover policy, and reserved-vs-pool split MUST be carried in `pricingSnapshotRef` ([`../PRD.md`](../PRD.md) §6.6) — they form the **eighth named segment `commitmentReservation`** (writer: Rating @ eval; no new writer, no pricing-side change), enumerated identically in [`01-foundation.md`](./01-foundation.md) §4.3, [`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.7, and [`../SEAMS.md`](../SEAMS.md) S1. +- **Balance write-back and sequencing (decided 2026-07-11, T-D-10)**: Contracts owns pool balances and **serializes** them. Rating, as the rated-outcome persister, publishes each outcome's **`CommitmentBalanceEffect`** (per-pool signed draw/refill deltas) to Contracts, idempotent on the emitting outcome's evaluation/correction key; Contracts applies effects per pool in received order, bumps the monotonic per-pool **`balanceVersion`**, and freezes `(balance, balanceVersion)` for subsequent context assembly — the `balanceVersion` in the `commitmentReservation` segment is exactly the frozen balance a unit observed, so cross-unit sequencing is `balanceVersion` order while the within-unit waterfall stays fully deterministic. A balance-affecting correction **cascades**: Contracts MUST emit re-resolution triggers for every later-`balanceVersion` unit that drew, or was rated overage against, the affected pool in the balance period — Rating routes each through `reresolve`, delta-only, each unit under its own pin ([`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) §4.4). The event schema is a Contracts/Rating cross-PRD obligation to mirror in their designs; Rating's own obligation still ends at recording the per-pool draw effect. + +### 4.2 Reservation Flavors and Pool Precedence (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-reservation-flavors-cmt` + +- **Consumption flavor**: the matched portion of measured usage prices at the reserved rate; the remainder prices at the on-demand rates resolved in steps 2–5. The reserved portion is **excluded from `commitmentPools[]` drawdown** — reservation precedes pools ([`../PRD.md`](../PRD.md) §6.6, §17.1 step 6). +- **Tier-counter exclusion** (adopted, money-affecting): the matched/allocated reserved quantity is excluded from the on-demand tier counter `Q` — only the on-demand remainder enters the row's bands, banded **from zero** (150K used against a 100K reservation ⇒ 100K at `reservedRate`, the remainder's `Q` starts at 0). Frozen semantics per pricing `inst-rv-tier-q`; the reservation joint fixture MUST include a tiered-remainder scenario ([`../../../pricing/docs/design/10-advanced-primitives.md`](../../../pricing/docs/design/10-advanced-primitives.md) §3). **Recompute contract (T-D-13)**: on a consumption split, **steps 3–5 re-run as a unit over the on-demand remainder** — the slice-03 band math re-bands the remainder from zero, then the same frozen overlay stack (step 4) and contract overlay (step 5) re-apply to the re-banded amount (identical survivor set, order, and adjustment values; only the base amount changes — [`04-overlays-precedence.md`](./04-overlays-precedence.md) §4.2). The reserved-rate portion is **not** re-overlaid (§4.3). Compiled, not configurable; lineage records the superseded full-`Q` pass and the authoritative remainder pass. +- **Capacity flavor**: emit `capacityCharge = reservedRate × reservedQuantity` regardless of measured usage — zero usage still bills the allocation; the charge is never reduced by absent usage and never draws `commitmentPools[]`; `reservedQuantity`, rate, and flavor are frozen in `pricingSnapshotRef` ([`../PRD.md`](../PRD.md) §6.6, §12 AC 20). The `capacityCharge` is emitted as its **own line** under the selected usage row's key, carrying flavor + match-id lineage (envelope shape: [`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.1). +- No `reservationMatch` ⇒ evaluation prices as pure usage; the reservation-match identifier, when present, is recorded in metadata **and** `pricingSnapshotRef` (§4.1 open covers the segment naming). +- The entitlement source feeding `reservationMatch` (OSS / Contracts) is a **hard precondition** tracked in [`../PRD.md`](../PRD.md) §17.3; Rating consumes the match from the frozen context and never resolves entitlement. + +### 4.3 Reserved-Rate Two-Source Rule (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-reserved-rate-sourcing-cmt` + +Seam **M9** ([`../SEAMS.md`](../SEAMS.md)), adopted under **T-D-08**: + +- **Self-service** reserved rates come from the **pinned catalog snapshot** — the `reservedRate`/`reservationFlavor` attributes on the selected usage row ([`../../../pricing/docs/design/10-advanced-primitives.md`](../../../pricing/docs/design/10-advanced-primitives.md) §3, [`../PRD.md`](../PRD.md) §6.6). +- **Negotiated RI-style** rates come from **Contracts**, arriving as the **step-5 contract overlay** — never as a catalog row. +- The rate step 6 prices with is the **post-step-5 effective value**: the snapshot attribute unless a negotiated contract term overlaid it at step 5 (contract outranks catalog base — [`../PRD.md`](../PRD.md) §17.1 step 5). The outranking happens in the overlay layer; step 6 makes no source choice of its own. +- A present `reservationMatch` whose rate resolves from **neither** source fails closed — the evaluator never guesses a reserved rate. + +### 4.4 Commitment Pool vs Prepaid Credit Grant (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-pool-vs-grant-cmt` + +Seam **M8** ([`../SEAMS.md`](../SEAMS.md)) — two constructs, one colliding word: + +| | Rating **commitment pool** | Pricing **prepaid credit grant** | +|---|---|---| +| Construct | `commitmentPools[]`, ordered waterfall at step 6 | Plan-attached wallet primitive (`grantAmount`, `creditUnit`, `expiryPolicy`, `autoRechargeAllowed`) | +| Definition SoR | Contracts | Pricing gear ([`../../../pricing/docs/design/10-advanced-primitives.md`](../../../pricing/docs/design/10-advanced-primitives.md) §3) | +| Balance owner | Contracts | Billing/Rating (execution); GA-gated — grants definable, not sellable until balance execution exists | +| Drawdown evaluator | **Rating, this slice** | **Billing-executed** — never enters step 6 | + +Normative: step 6 MUST NOT draw down a wallet-grant balance; the bare word "prepaid" MUST NOT be +used unqualified in Rating artifacts — always "commitment pool" (this slice) or "prepaid credit +grant" (the wallet, outside the §17.1 per-line order). + +### 4.5 Obligations, Reversals, and Period Boundaries (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-obligations-boundary-cmt` + +- **`TrueUpObligation`**: shape owned here `(amount, period, contract ref)`; emission envelope owned by the Foundation ([`01-foundation.md`](./01-foundation.md) §3.1); rides the Rating handoff ([`../PRD.md`](../PRD.md) §9.2) and is **executed by Billing** — surfaced, never posted. +- **True-up formulas (normative, T-D-14)**: `prepaid_drawdown` pools surface **no** pool-driven true-up (the pool was billed at sale; unused balance follows rollover). For `committed_rate` pools, the period-driven unit at the `AnchorPeriod` end evaluates per the contract's `commitmentBasis`: **quantity basis** — `TrueUpObligation.amount = max(0, committedQuantity − consumedInCommitQuantity) × frozen in-commit rate`; **spend basis** — `max(0, committedSpend − inCommitBilledAmount)`. Amounts leave at full intermediate precision in price currency (step-8 FX converts downstream); a zero-amount obligation is not emitted. +- **Guard order**: step-6 effects complete inside the steps-4–7 sequence, **before** the `EmissionGuard` non-negative clamp ([`01-foundation.md`](./01-foundation.md) §4.4); the waterfall itself cannot produce a negative component (a pool absorbs at most the remaining measure), so a negative line at the guard always originates elsewhere. +- **Reversals**: pool refill on correcting/negative usage, compensating deltas, and the never-negative rule are evaluated by [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) under the Foundation correction keys ([`01-foundation.md`](./01-foundation.md) §4.2); this slice guarantees the drawdown effect carries enough lineage (per-pool draws, order, rates) for a deterministic refill. +- **Plan-change boundary**: commitment-pool carry-vs-reset across a plan change is consumed frozen from the snapshot (default reset unless marked carry) and evaluated by [`09-period-plan-change.md`](./09-period-plan-change.md) ([`../PRD.md`](../PRD.md) §17.2); this slice's evaluator runs unchanged on each side of the split. + +## 5. Traceability + +- **PRD**: §6.6 (`fr-commitment-drawdown`, `fr-reservation-consumption-flavor`, `fr-capacity-charge`), §6.2 (`fr-committed-usage`, hybrid attachment), §6.3 `fr-evaluation-order`, §17.1 step 6 + reserved-capacity note, §17.2 (plan-change pool carry-vs-reset), §17.3 (reservation entitlement precondition), §17.4 (multi-pool/rollover Follow-on), §12 ACs 5/19/20 (FR rationales cite "AC 22/23" — the pre-existing vendored numbering drift tracked in [`../DECISIONS.md`](../DECISIONS.md) open items). +- **Seams**: M8 (§4.4), M9 (§4.3); S1 segment-naming residue recorded in §4.1 — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-05 (composition, not modelKind), T-D-08 (reserved-rate two-source split), T-D-09 (`commitmentReservation` segment), T-D-10 (balance write-back + cascade), T-D-13 (steps-3–5 remainder re-run), T-D-14 (pool flavors + true-up formulas), T-D-15 (period-driven true-up unit) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md). +- **Pricing design set**: [`../../../pricing/docs/design/10-advanced-primitives.md`](../../../pricing/docs/design/10-advanced-primitives.md) (reserved attributes, tier-counter exclusion, prepaid credit grant, GA gate). +- **Step slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, envelope, guards, keys); [`03-metering-models.md`](./03-metering-models.md) (remainder band math); [`04-overlays-precedence.md`](./04-overlays-precedence.md) (step-5 overlay, negotiated RI); [`06-coupons.md`](./06-coupons.md) (downstream step 7); [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (reversals); [`09-period-plan-change.md`](./09-period-plan-change.md) (period/plan-change wrappers); [`11-consumer-contracts.md`](./11-consumer-contracts.md) (Contracts/Billing boundaries). diff --git a/gears/bss/rating/docs/design/06-coupons.md b/gears/bss/rating/docs/design/06-coupons.md new file mode 100644 index 000000000..bee487ab3 --- /dev/null +++ b/gears/bss/rating/docs/design/06-coupons.md @@ -0,0 +1,318 @@ + + + +# DESIGN — Coupons (Slice 6) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-coupons` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Placement and the FX Split (normative)](#41-placement-and-the-fx-split-normative) + - [4.2 Stacking Policies (normative)](#42-stacking-policies-normative) + - [4.3 applyScope Attachment and Hybrid Split-Back (normative)](#43-applyscope-attachment-and-hybrid-split-back-normative) + - [4.4 Frozen Coupon Snapshot and Fail-Closed Rules (normative)](#44-frozen-coupon-snapshot-and-fail-closed-rules-normative) + - [4.5 Snapshot Segment and Discount Lineage (normative)](#45-snapshot-segment-and-discount-lineage-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **step-7 evaluator**: coupons are the Promotions overlay applied to the +**post-commitment** resolved commercial price — after the overlay/contract/commitment steps 4–6 +and split around FX at step 8 ([`../PRD.md`](../PRD.md) §6.8, §17.2 order chain). Rating owns +**application semantics only**: which of the frozen, already-redeemed coupon snapshots applies to +which line, in what order, with what recorded lineage. The coupon entity lifecycle — creation, +campaigns, distribution, redemption limits, fraud controls — is Promotions' and explicitly out of +Rating scope ([`../PRD.md`](../PRD.md) §5.2). + +Two invariants shape the slice. First, **the ordering is compiled in**: price-currency coupons +apply at step 7 before FX; billing-currency coupons apply only after step 8, on the +billing-currency amount, under the same `fxTableVersion` ([`01-foundation.md`](./01-foundation.md) +§3.6). Second, **policy comes only from the frozen snapshot**: a snapshot missing `applyScope` +(or `stackSequence` under `ordered_stack`) fails closed — Rating never infers a coupon rule from +mutable campaign state, and replay uses the pinned snapshot, never live Promotions state +([`../PRD.md`](../PRD.md) §6.8, §9.2). The slice owns no scope-key seam; its cross-gear surface +is the S1 coupon segment of `pricingSnapshotRef` (§4.5). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-coupon-application-order` | A registered step-7 evaluator over the post-commitment amount plus a **deferred billing-currency pass** after step 8 (§4.1): `settlementCurrency = price` before FX, `= billing` after, same `fxTableVersion`; applied ids + pre-/post-discount amounts always recorded (§4.5). | +| `cpt-cf-bss-rating-fr-coupon-stacking` | `exclusive_best` default (single best coupon = lowest resulting charge) vs campaign-linked `ordered_stack` (ascending `stackSequence`, compounding); incompatible pairs fail closed; missing policy fields fail closed, never inferred (§4.2, §4.4). | +| `cpt-cf-bss-rating-fr-hybrid-pricing` | Per-`applyScope` attachment on hybrid plans: `usage`/`recurring` bind to that line; `line_total` applies once as a plan-scoped overlay and splits back pro-rata across the two lines at full precision (§4.3). | +| `cpt-cf-bss-rating-fr-snapshot-carry` | The applied coupon id(s) + stacking policy are a **Rating-written segment** of `pricingSnapshotRef`, sealed by the `SnapshotComposer` at emission (§4.5; SEAMS S1). | +| `cpt-cf-bss-rating-fr-evaluation-order` | Step 7 is a fixed slot after commitment and before FX; the intra-step order (eligibility → currency partition → stacking → attachment → record) is equally compiled in (§3.6). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | Step-7 evaluator | No Promotions call on the hot path — coupon snapshots arrive frozen in the context; both passes are in-process arithmetic | Load test; targets provisional ([`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partitioning contract | Stateless per-unit application; no shared redemption state is read or written at evaluation | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Fail-closed guards | Missing `applyScope` / `stackSequence` / unresolvable enum values fail closed — never an inferred discount ([`../PRD.md`](../PRD.md) §12 AC 16) | Retry + conformance fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Not load-bearing at step 7 itself — coupons discount the already-resolved line; listed because the rows being discounted resolve on the adopted 8-axis key upstream (slice 02) and the coupon segment joins that same composed ref. | + +No slice-local ADR: coupon semantics are PRD-normative ([`../PRD.md`](../PRD.md) §6.8, §17.2); +the segment ownership follows **T-D-03** and replay follows **T-D-04** +([`../DECISIONS.md`](../DECISIONS.md)). + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-cpn` + +```text +Step-7 evaluator (this slice) EligibilityFilter → currency partition → StackingResolver + │ (fixed §17.1 slot 7) (exclusive_best | ordered_stack) → ScopeAttacher + │ + deferred billing-currency pass (after step 8) + ▼ +Evaluation pipeline (01) EvaluationPipeline · SnapshotComposer (coupon segment) · + MetadataRecorder (discount lineage) · EmissionGuard + │ + ▼ +Frozen inputs (external SoRs) coupon snapshots (Promotions) · post-step-6 line state · + fxTableVersion (Finance via step 8, billing-currency pass) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The step-7 evaluator, both application passes, stacking/attachment rules | Rust module in the `rating` gear (rating-core crate), registered into the Foundation pipeline | +| Domain | Coupon-snapshot input shape, application/lineage value objects (§3.1) | Rust; GTS + Rust domain structs | +| Infrastructure | **None** — no store, no cache; snapshots arrive frozen in the `EvaluationContext` | n/a | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Apply, never own + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-apply-never-own-cpn` + +Rating applies frozen coupon snapshots and records the result; it never creates, expires, +counts, or invalidates a redemption — the coupon lifecycle is Promotions' +([`../PRD.md`](../PRD.md) §5.2). Nothing in this slice writes toward Promotions. + +#### Policy from the snapshot only + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-policy-from-snapshot-cpn` + +Every application decision — scope, stacking, sequence, validity, settlement currency — is read +from the frozen snapshot fields; an absent policy field is a fail-closed error, never a default +guessed at evaluation time ([`../PRD.md`](../PRD.md) §6.8, §9.2 "never infers coupon rules from +mutable campaign UI state"). + +#### Lineage is part of the outcome + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-lineage-first-cpn` + +Pre-/post-coupon amounts and applied ids are not diagnostics — they are the contractual handoff +that lets Billing/Tax choose gross-vs-net treatment and lets audit reproduce the discount +([`../PRD.md`](../PRD.md) §5.2; [`01-foundation.md`](./01-foundation.md) §4.4). An application +without recorded lineage is an invalid outcome. + +### 2.2 Constraints + +#### Fixed slot, compiled FX split + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-fixed-slot-cpn` + +Step 7 always runs after steps 4–6 on the post-commitment amount; the price-vs-billing currency +split around step 8 is part of the compiled §17.1 order — there is no placement or reordering +configuration ([`../PRD.md`](../PRD.md) §6.8, §17.1 steps 7–8). + +#### No redemption mutation, snapshot-only replay + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-no-redemption-mutation-cpn` + +Evaluation and re-resolution never read live Promotions state: corrections and replays reuse the +coupon snapshot pinned in `pricingSnapshotRef` (W2 / T-D-04), so a campaign edit or redemption +event after the pin can never change a replayed outcome. + +#### Promotions contract maturity + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-promotions-maturity-cpn` + +The Promotions PRD does not exist yet; [`../PRD.md`](../PRD.md) §17.2 is the **Rating-side +stub** and this slice's contract source. **Open** — field names and the coupon-snapshot event +contract MUST be aligned with Promotions before production coupon rating +([`../PRD.md`](../PRD.md) §15, §16). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-domain-model-cpn` + +- **`CouponSnapshot`** — the frozen input, minimum fields per [`../PRD.md`](../PRD.md) §17.2: `couponId`, `adjustmentType` (`percent | fixed_amount`), `value`, `settlementCurrency` (`price | billing`), `applyPerTierBand`, `applyScope` (`usage | recurring | line_total`), `stackSequence` (required under `ordered_stack`), validity, applicability filters, redemption eligibility. +- **`StackingPolicy`** — `exclusive_best | ordered_stack`; carried by the snapshot/campaign link, recorded into the coupon segment (§4.5). +- **`CouponApplication`** — one applied coupon on one line: id, pass (`price | billing` currency), basis amount, discount amount, resulting amount. +- **`DiscountLineage`** — per line and per pass: pre-/post-coupon amounts, applied ids, stacking policy, and the `line_total` split-back shares on hybrid plans (§4.3). + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-coupon-evaluator-cpn` + +- **`Step7Evaluator`** — the registered evaluator; runs the price-currency pass at step 7 and provides the deferred billing-currency pass invoked after step 8; enforces the fail-closed guards. +- **`EligibilityFilter`** — evaluates validity at `t`, applicability filters, and redemption eligibility strictly against the frozen snapshot fields. +- **`StackingResolver`** — `exclusive_best`: selects the single coupon yielding the lowest resulting charge; `ordered_stack`: folds ascending `stackSequence`, each step applying to the prior output (§4.2). +- **`ScopeAttacher`** — routes per `applyScope` to the usage line, the recurring line, or the plan-scoped `line_total` with deterministic split-back; routes `applyPerTierBand` to band-level vs total-after-tier-math application (§4.3). + +Applied ids + stacking policy reach the composed ref via the Foundation `SnapshotComposer`; +lineage rides the outcome envelope via the `MetadataRecorder` +([`01-foundation.md`](./01-foundation.md) §3.2). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-step7-evaluator-cpn` + +The **step-evaluator contract** (in-process, registered): input = post-step-6 line state + the +frozen coupon snapshot set from the context; output = `CouponApplication[]` + `DiscountLineage`, +applied to the line amount. A second application point — the **billing-currency pass** — is +invoked by the pipeline after step 8 with the converted amount and the step-8 `fxTableVersion`. +Fail-closed problem values: missing `applyScope`; missing `stackSequence` under `ordered_stack`; +an unrecognized `adjustmentType` / `settlementCurrency` value; an incompatible pair arriving +both-applicable (§4.2); an `exclusive_best` candidate set whose benefit comparison is undefined +(§4.2 open). External boundary contracts are owned by +[`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation.md`](./01-foundation.md) (slot registration, `SnapshotComposer`, +`MetadataRecorder`, `EmissionGuard`, determinism keys), on +[`05-commitments-reservations.md`](./05-commitments-reservations.md) (the post-commitment amount +is the step-7 basis), on [`03-metering-models.md`](./03-metering-models.md) (the graduated +outcome exposes marginal-band amounts for `applyPerTierBand = true`), and on +[`04-overlays-precedence.md`](./04-overlays-precedence.md) (coupon and partner discount both +apply — partner in step 4, coupon in step 7; never mutually exclusive, +[`../PRD.md`](../PRD.md) §17.2). Downstream: [`07-currency-fx.md`](./07-currency-fx.md) hosts +the step-8 conversion the billing-currency pass follows; +[`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) replays the pinned coupon +snapshot; [`09-period-plan-change.md`](./09-period-plan-change.md) owns the floor/cap boundary +the §15 clawback open belongs to. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Promotions | Frozen coupon snapshots (field list [`../PRD.md`](../PRD.md) §17.2); lifecycle, campaigns, redemption limits stay Promotions-side | [`../PRD.md`](../PRD.md) §9.2 coupon snapshot contract; fail-closed compatibility | +| Finance (via step 8) | The `fxTableVersion` / locked-rate id the billing-currency pass reuses | [`../PRD.md`](../PRD.md) §6.8, §6.9 | +| Billing / Tax | Consume discount lineage for gross-vs-net treatment; Rating supplies lineage, not ordering | [`../PRD.md`](../PRD.md) §5.2 | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-step7-line-cpn` + +**Step 7 on one line — price-currency pass** (within `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. Filter the frozen coupon set: validity at `t`, applicability filters, redemption eligibility, and complete policy fields — any absent policy field fails closed (§4.4). +2. Partition candidates by `settlementCurrency`: the `price` set proceeds now; the `billing` set is deferred to the post-step-8 pass. +3. Resolve stacking (§4.2): `exclusive_best` selects the single lowest-resulting-charge coupon; `ordered_stack` folds ascending `stackSequence` over the prior output. +4. Attach per `applyScope` (§4.3): `usage`/`recurring` to that line; `line_total` once, plan-scoped, split back pro-rata at full precision; `applyPerTierBand` routes band-level application. +5. Record: applied ids + stacking policy into the coupon segment; pre-/post-discount amounts into lineage; the line proceeds to step 8. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-billing-currency-pass-cpn` + +**Billing-currency pass** (after step 8): + +1. Apply the deferred `settlementCurrency = billing` candidates to the billing-currency amount produced by step 8, under the **same `fxTableVersion`** ([`../PRD.md`](../PRD.md) §6.8, §17.1 step 8). +2. Stacking, attachment, and recording rules are identical to the price-currency pass; both passes complete before the step-9 `EmissionGuard`, whose non-negative rule covers the post-coupon amount ([`../PRD.md`](../PRD.md) §17.1 step 9). + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-none-cpn` + +**None owned.** Coupon entities, campaigns, and redemption state live in Promotions; the applied +result persists only inside the rated outcome (segment + lineage) owned by Rating; there is no +coupon cache — the snapshot set arrives in the context +([`01-foundation.md`](./01-foundation.md) §3.7). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-cpn` + +A stateless registered evaluator in the `rating` gear (rating-core crate) with +two in-process application points (step 7 and the post-step-8 hook) — identical topology to the +Foundation ([`01-foundation.md`](./01-foundation.md) §3.8); nothing slice-specific beyond +registration. + +## 4. Additional Context + +### 4.1 Placement and the FX Split (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-placement-fx-split-cpn` + +- Coupons apply at **step 7**, strictly after steps 4–6, on the **post-commitment** line amount; the normative chain is Catalog base → PriceOverlays → Customer/contract → Commitment → **Coupon** → FX → Emit ([`../PRD.md`](../PRD.md) §6.8, §17.2). +- `settlementCurrency = price` coupons apply at step 7 in **price currency, before FX**; `settlementCurrency = billing` coupons apply **only after step 8**, on the billing-currency amount, under the **same `fxTableVersion`** ([`../PRD.md`](../PRD.md) §6.8, §12 AC 16). The split is compiled into the pipeline ([`01-foundation.md`](./01-foundation.md) §3.6) — no knob. +- Native multi-currency: when invoice currency equals the row's price currency, step 8 is skipped ([`../PRD.md`](../PRD.md) §17.1 step 2); the two passes then operate on the same currency but keep their fixed relative order — price-currency set first, billing-currency set at the (skipped) FX point. +- Coupon and partner discount **both** apply (partner in step 4, coupon in step 7); overlay stacking never excludes a coupon and vice versa ([`../PRD.md`](../PRD.md) §17.2). + +### 4.2 Stacking Policies (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-stacking-cpn` + +- **`exclusive_best`** (default): exactly one coupon applies per line — the one yielding the largest customer benefit, i.e. the **lowest resulting charge**; all others MUST NOT apply on the same line ([`../PRD.md`](../PRD.md) §6.8, §12 AC 16). +- **Open — equal-benefit tie-break**: the PRD does not pin the order between two coupons producing an identical resulting charge; determinism requires a total order. Default proposal: ascending `couponId` as the stable tie-break; set with Promotions before production coupon rating ([`../PRD.md`](../PRD.md) §15 Promotions row). +- **Open — mixed-settlement comparison**: when an `exclusive_best` candidate set mixes `settlementCurrency` values, the benefit-comparison basis across the FX boundary is an absent policy — evaluation fails closed until it is pinned with Promotions/Finance ([`../PRD.md`](../PRD.md) §15); never a guessed cross-currency comparison. +- **`ordered_stack`**: applies only when a Promotions campaign explicitly links coupons with `stackSequence`; application folds in ascending sequence, each step using the prior step's output (compounding); a missing `stackSequence` under `ordered_stack` fails closed ([`../PRD.md`](../PRD.md) §6.8). +- **Open — mixed-settlement `ordered_stack`**: a linked sequence mixing `settlementCurrency` values cannot fold in sequence order — the compiled two-pass placement (price-currency at step 7, billing-currency after step 8) would contradict the `stackSequence`. Such a campaign **fails closed** until the rule is pinned with Promotions (the mirror of the `exclusive_best` mixed-settlement open); the candidate rule — fold within each pass, sequence preserved per pass — goes to the same workshop. +- **Incompatible pairs** fail closed at **redemption bind time** (Promotions-side); if an incompatible pair nevertheless arrives both-applicable on one line, the bind invariant is violated and evaluation fails closed — never a silent pick ([`../PRD.md`](../PRD.md) §6.8). + +### 4.3 applyScope Attachment and Hybrid Split-Back (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-applyscope-cpn` + +- `applyScope ∈ {usage, recurring, line_total}`. The documented default `line_total` is an **authoring-side** default Promotions writes into the snapshot ([`../PRD.md`](../PRD.md) §17.2); a snapshot *arriving* without `applyScope` still fails closed — Rating never assumes the default (`fr-coupon-stacking`). +- On a hybrid plan ([`../PRD.md`](../PRD.md) §6.2): `usage`/`recurring` binds the coupon to that emitted line; `line_total` applies **once** to the combined total as a plan-scoped overlay and is split back **pro-rata to the two lines' pre-coupon amounts** — deterministic and exact because the split runs at full intermediate precision (no rounding in rating-core, [`01-foundation.md`](./01-foundation.md) §4.4). +- **`applyPerTierBand`**: default (false) applies the discount to the **total line amount after tier math** (a 10% coupon on a graduated total of 100 discounts 10); `true` applies per **marginal band**, consuming the band-level amounts exposed by [`03-metering-models.md`](./03-metering-models.md) ([`../PRD.md`](../PRD.md) §17.2, §12 AC 16). On a line without bands (`flat`/`per_unit`/`package`), `applyPerTierBand = true` degrades to the total-amount application (numerically identical) and is recorded as applied-to-total in lineage. + +### 4.4 Frozen Coupon Snapshot and Fail-Closed Rules (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-fail-closed-snapshot-cpn` + +- The consumption contract (Rating ← Promotions) is the frozen snapshot with at minimum: `couponId`, `adjustmentType` (`percent | fixed_amount`), `value`, `settlementCurrency` (`price | billing`), `applyPerTierBand`, `applyScope`, `stackSequence` (required under `ordered_stack`), validity, applicability filters, redemption eligibility ([`../PRD.md`](../PRD.md) §17.2, §9.2). +- Fail-closed set (never inferred, never defaulted at evaluation): missing `applyScope`; missing `stackSequence` under `ordered_stack`; unrecognized `adjustmentType` / `settlementCurrency` values; a `fixed_amount` coupon whose `value` denomination does not match its pass (price-currency pass ⇒ price currency; billing-currency pass ⇒ billing currency — the denomination is a frozen snapshot field, never converted by Rating); the §4.2 opens (mixed-settlement comparison; mixed-settlement `ordered_stack`) until pinned. +- Eligibility (validity window at `t`, applicability filters, redemption eligibility) is evaluated **against the frozen snapshot fields only**; expiry, redemption counting, and fraud controls remain Promotions-side lifecycle ([`../PRD.md`](../PRD.md) §5.2). +- **Determinism**: the same frozen coupon snapshot + the same inputs ⇒ byte-identical outcome ([`01-foundation.md`](./01-foundation.md) §4.2); open-period and posted-period corrections replay the coupon snapshot pinned in `pricingSnapshotRef` (W2 / T-D-04) — a Promotions-side change after the pin never alters a replay. + +### 4.5 Snapshot Segment and Discount Lineage (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-segment-lineage-cpn` + +- **S1 segment**: `applied coupon id(s) + stacking policy` is the Rating-written-at-evaluation segment of `pricingSnapshotRef` owned by this slice, sealed by the `SnapshotComposer` at emission and immutable thereafter ([`../SEAMS.md`](../SEAMS.md) D/S1; [`01-foundation.md`](./01-foundation.md) §4.3). +- **Lineage**: pre-/post-coupon amounts and applied ids — per line and per pass (price- and billing-currency) — ride the outcome envelope via the `MetadataRecorder` for Billing/Tax gross-vs-net treatment and audit ([`../PRD.md`](../PRD.md) §5.2, §6.8; [`01-foundation.md`](./01-foundation.md) §4.4). Rating supplies lineage, not the gross-vs-net ordering decision. +- **Floor interplay** (boundary, not owned here): whether a contractual period floor claws back coupon discount is a [`../PRD.md`](../PRD.md) §15 open (default proposal: the floor compares the post-coupon total); the floor/cap boundary is [`09-period-plan-change.md`](./09-period-plan-change.md)'s — this slice guarantees the lineage that keeps either answer computable. + +## 5. Traceability + +- **PRD**: §6.8 (`fr-coupon-application-order`, `fr-coupon-stacking`), §6.2 (`fr-hybrid-pricing` applyScope attachment), §6.1 (`fr-snapshot-carry`), §6.3 `fr-evaluation-order`, §17.1 steps 7–8, §17.2 (coupon boundary contract — field list + order chain), §9.2 (Promotions coupon snapshot contract), §5.2 (lifecycle exclusions), §15 (Promotions contract alignment; floor-clawback open), §12 AC 16 "Coupon application order and stacking" (the `fr-coupon-*` rationales cite "AC 18" — the pre-existing vendored numbering drift tracked in [`../DECISIONS.md`](../DECISIONS.md) open items). +- **Seams**: no scope-key seam owned; S1 coupon segment (§4.5) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-03 (one ref, Rating composition SoR), T-D-04 (snapshot-only replay) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md) (context only — §1.2). +- **Step slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, composer, recorder, guards); [`03-metering-models.md`](./03-metering-models.md) (band-level basis for `applyPerTierBand`); [`04-overlays-precedence.md`](./04-overlays-precedence.md) (partner discount coexistence); [`05-commitments-reservations.md`](./05-commitments-reservations.md) (post-commitment basis); [`07-currency-fx.md`](./07-currency-fx.md) (step-8 conversion, `fxTableVersion`); [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (pinned-snapshot replay); [`09-period-plan-change.md`](./09-period-plan-change.md) (floor/cap boundary); [`11-consumer-contracts.md`](./11-consumer-contracts.md) (Promotions boundary contract). diff --git a/gears/bss/rating/docs/design/07-currency-fx.md b/gears/bss/rating/docs/design/07-currency-fx.md new file mode 100644 index 000000000..ab4acdcd2 --- /dev/null +++ b/gears/bss/rating/docs/design/07-currency-fx.md @@ -0,0 +1,301 @@ + + + +# DESIGN — Multi-Currency & FX (Slice 7) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-currency-fx` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Currency Role Separation (normative)](#41-currency-role-separation-normative) + - [4.2 FX Policy Semantics (normative)](#42-fx-policy-semantics-normative) + - [4.3 FX-Lock Snapshot Segment (normative)](#43-fx-lock-snapshot-segment-normative) + - [4.4 Ordering and Precision at the FX Boundary (normative)](#44-ordering-and-precision-at-the-fx-boundary-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **step-8 evaluator** of the §17.1 order ([`../PRD.md`](../PRD.md) §6.9, §17.1): +the only point in the platform where an authoritative currency conversion happens. It keeps three +currency roles strictly apart — **price currency** (the selected row's currency; per-market rows +are first-class catalog rows, never FX-derived), **billing currency** (the payer's invoice +currency, frozen by Subscriptions at activation), and **presentment currency** (portal display FX, +non-authoritative and outside rating-core) — and converts exactly once, only when billing ≠ price. + +Conversion runs as a pure function over **frozen Finance inputs**: the FX table and lock policy +arrive with a `fxTableVersion` that is part of the determinism tuple +([`./01-foundation.md`](./01-foundation.md) §4.2). Two deterministic policies exist and no third: +**per-window rate-lock** (final at event time) and **invoice-period FX** (provisional on the hot +path, authoritative re-rate **by delta at period close** under the slice-08 correction keys — +never by mutating the provisional line). The FX-lock id is a Rating-written segment of +`pricingSnapshotRef` (SEAMS S1); conversion never rounds — Billing rounds in billing currency +after conversion; a missing FX record where one is required fails closed, never a provider default. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-multi-currency` | Three declared roles (§4.1): price (selected row; distinct per-market rows, never FX-derived), billing (payer invoice currency via the frozen Subscriptions binding), presentment (display-only, outside rating-core, labelled estimates). The `CurrencyRoleResolver` converts only when billing ≠ price; equal currencies skip step 8 (§17.1 step 2, native multi-currency). | +| `cpt-cf-bss-rating-fr-fx-policy` | The `FxPolicyApplier` executes exactly two policies over the frozen Finance table (§4.2): per-window rate-lock (final at event time) and invoice-period FX (provisional, flagged; close-time `fxTableVersion` authoritative; delta at close via slice 08). Every conversion records `fxTableVersion` / locked-rate id; no implicit or provider-default rate can be emitted. | +| `cpt-cf-bss-rating-fr-evaluation-order` | The evaluator registers into the fixed step-8 slot: after price-currency coupons (step 7), before emission (step 9); there is no configuration surface to move it. | +| `cpt-cf-bss-rating-fr-coupon-application-order` | The `settlementCurrency` split (§4.4): `price` coupons complete in step 7 before conversion; `billing` coupons apply after conversion on the billing-currency amount under the **same** `fxTableVersion`. The placement is this slice's; the coupon semantics (stacking, applyScope) stay slice 06's. | +| `cpt-cf-bss-rating-fr-snapshot-carry` | The FX-lock id is the Rating-written FX segment of `pricingSnapshotRef` (§4.3, SEAMS S1); the recorded `fxTableVersion` rides the outcome metadata; both immutable once the ref is sealed. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | Invoice-period policy split | The hot path never waits for close-time rates: provisional amount at the locked/spot rate from the frozen table, no I/O inside the step; the authoritative re-rate is an off-hot-path delta | Load test; **targets provisional — NFR workshop** ([`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-resilience` | Fail-closed FX guard | Missing FX table / policy / lock record with billing ≠ price ⇒ fail closed, never a provider-default rate; retries replay the same `fxTableVersion` to the same amount | Chaos/retry test | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Frozen-table pinning | The table version is pinned per evaluation unit — no shared mutable rate state, no cross-partition coordination | Design + load test | +| Decimal precision of converted amounts | Full-precision conversion | Conversion never rounds (§4.4); Billing rounds in billing currency; the concrete DECIMAL precision is the Foundation open ([`./01-foundation.md`](./01-foundation.md) §4.4) | **Open — set with Billing** | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | `currency` is an axis of the adopted 8-axis key: each market is its own catalog row selected at step 2 — step 8 never fabricates a missing market row via FX derivation. | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | The key definition carrying the `currency`/`region` axes; per-`(currency, region)` rows are authored independently in the pricing gear ([`../../../pricing/docs/design/04-currency-tax.md`](../../../pricing/docs/design/04-currency-tax.md)). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-fx` + +```text +Step-8 evaluator (this slice) CurrencyRoleResolver · FxPolicyApplier · FxLockRecorder · + │ (registers into the fixed §17.1 BillingCurrencyCouponPass · FxCloseDeltaCalculator + ▼ step-8 slot) +Evaluation pipeline (Foundation) EvaluationPipeline · SnapshotComposer · DeterminismGuard · + EmissionGuard · MetadataRecorder + │ + ▼ +Frozen inputs (external SoRs) FX tables + lock policy, fxTableVersion (Finance) · + per-(currency, region) price rows (pricing) · (currency, region) + binding (Subscriptions) · coupon snapshots (Promotions) · + periodState at close (Billing) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The step-8 evaluator and the close-time re-rate math used by slice 08 | Rust module in the `rating` gear (rating-core crate) | +| Domain | Currency roles, FX policy semantics, `FxApplication` / lock-segment shapes | Rust; GTS + Rust domain structs | +| Infrastructure | **None authoritative** — a non-authoritative cache of frozen FX table pages keyed by `fxTableVersion`; loss degrades latency, never correctness | In-process cache; Rating persistence | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Three roles, one conversion point + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-currency-roles-fx` + +Price, billing, and presentment currency are distinct declared roles, never inferred from one +another. The only authoritative conversion in the platform is step 8, and it runs only when +billing ≠ price; presentment FX is never computed here ([`../PRD.md`](../PRD.md) §6.9). + +#### No unrecorded FX + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-no-implicit-fx` + +Every converted amount carries its policy identity: policy kind + `fxTableVersion` / locked-rate +id. An implicit or provider-default rate is a defect; absence of the required FX record fails +closed ([`./01-foundation.md`](./01-foundation.md) §3.3). + +#### Convert at full precision, never round + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-full-precision-conversion-fx` + +Conversion output keeps full intermediate precision. Rounding is Billing's, in billing currency, +after conversion (§17.1 rating-core/Billing boundary; [`./01-foundation.md`](./01-foundation.md) §4.4). + +### 2.2 Constraints + +#### FX tables and policy are Finance's + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-finance-sor-fx` + +FX rate tables and lock policies arrive **frozen** with a `fxTableVersion` +([`../PRD.md`](../PRD.md) §9.2 Finance FX input contract). Rating never sources, derives, or +interpolates a rate; the boundary contract is owned by +[`11-consumer-contracts.md`](./11-consumer-contracts.md). + +#### The (currency, region) binding is consumed, never re-derived + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-binding-consumed-fx` + +Subscriptions freezes the `(currency, region)` binding into `pricingSnapshotRef` at activation +(SEAMS S1). Step 8 reads the bound billing currency from the frozen context; re-deriving it at +evaluation time is a defect. + +#### Presentment is outside rating-core + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-presentment-outside-fx` + +Portal display FX is non-authoritative, computed outside rating-core, and MUST be labelled estimates +([`../PRD.md`](../PRD.md) §6.9). Nothing in the resolved outcome depends on a presentment amount. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-fx` + +- **`CurrencyRoles`** — value object bound per line from the frozen context: `priceCurrency` (currency of the step-2 selected row), `billingCurrency` (payer account/contract invoice currency via the frozen Subscriptions binding); presentment is deliberately absent from the model. +- **`FxPolicyRecord`** — frozen Finance input: policy kind (`per_window_rate_lock` \| `invoice_period`), `fxTableVersion`, locked-rate id (rate-lock), and the rate material for the pair at `t`. +- **`FxApplication`** — the outcome fragment: pre-conversion amount (price currency), post-conversion amount (billing currency), policy kind, `fxTableVersion` / locked-rate id, and the **provisional** flag (invoice-period only). +- **`FxLockSegment`** — the S1 snapshot segment: FX-lock id (if any), written by Rating at evaluation (§4.3). +- **`FxCloseDelta`** — the close-time re-rate difference for provisional lines: same frozen tuple except the close-time `fxTableVersion`; leaves under the slice-08 correction key (§4.2). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-conversion-fx` + +- **`CurrencyRoleResolver`** — binds `CurrencyRoles` from the frozen context; short-circuits step 8 when billing = price (native multi-currency, §17.1 step 2). Authoritative: the slice-02 FX-skip flag is advisory metadata, re-derived here. +- **`FxPolicyApplier`** — applies the frozen table under the selected policy; produces `FxApplication`; fails closed on any missing table / policy / lock record. +- **`FxLockRecorder`** — hands the FX-lock segment to the `SnapshotComposer` before sealing; stamps `fxTableVersion` / locked-rate id into the outcome metadata (`MetadataRecorder`). +- **`BillingCurrencyCouponPass`** — re-invokes the slice-06 coupon evaluator for `settlementCurrency = billing` coupons on the converted amount, same `fxTableVersion` (§4.4). +- **`FxCloseDeltaCalculator`** — pure re-rate math (`FxCloseDelta`) invoked by the slice-08 wrapper at period close; owns no path of its own. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-convert-fx` + +The **step-8 evaluator contract** (conceptual; invoked by the Foundation pipeline): +`applyFx(lineAmount@priceCurrency, CurrencyRoles, FxPolicyRecord) → FxApplication`. Pure; same +inputs ⇒ byte-identical output (the `fxTableVersion` at each stage is part of the replay inputs, +[`../PRD.md`](../PRD.md) §6.9). Fail-closed problem values: missing FX record / policy / lock with +billing ≠ price; unbound billing currency (torn Subscriptions segment — rejected by the +`SnapshotComposer`, [`./01-foundation.md`](./01-foundation.md) §4.3). + +The Finance FX input contract and the Billing handoff (provisional flag, rounding, close-time +authority) are owned by [`11-consumer-contracts.md`](./11-consumer-contracts.md); the close delta +leaves through the Foundation `cpt-cf-bss-rating-interface-reresolve-fnd` under slice 08. + +### 3.4 Internal Dependencies + +Foundation ([`01-foundation.md`](./01-foundation.md)): step-slot registration, determinism tuple, +`SnapshotComposer`, `EmissionGuard`. [`06-coupons.md`](./06-coupons.md): price-currency coupons +complete before this step; the billing-currency pass re-invokes the slice-06 evaluator — semantics +stay there, placement is here. [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md): +the invoice-period close delta is a correction under its keys. +[`09-period-plan-change.md`](./09-period-plan-change.md): floor/cap set in price currency converts +for comparison under the same policy + `fxTableVersion` (§17.2) — slice 09's obligation, this +slice's recorded policy identity. [`11-consumer-contracts.md`](./11-consumer-contracts.md): boundary contracts. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Finance | FX tables + lock policy, `fxTableVersion` / locked-rate ids | PRD §9.2 Finance FX; [`11-consumer-contracts.md`](./11-consumer-contracts.md) | +| Pricing (Product Catalog) | per-`(currency, region)` price rows (first-class, never FX-derived), ISO 4217 minor-unit amounts | PRD §9.2 read-model contract; pricing [`design/04`](../../../pricing/docs/design/04-currency-tax.md) | +| Subscriptions | `(currency, region)` binding frozen at activation | SEAMS S1; PRD §9.2 Subscriptions input | +| Promotions | frozen coupon snapshots incl. `settlementCurrency` | PRD §9.2 Promotions; [`06-coupons.md`](./06-coupons.md) | +| Billing | rounds in billing currency after conversion; period close fixes the authoritative close-time `fxTableVersion` | PRD §9.2 Billing; §17.1 rating-core/Billing boundary | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-step8-convert-fx` + +**Step 8 within one line** (the step-8 leg of `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. Resolve `CurrencyRoles`; if billing = price, skip conversion entirely (native multi-currency; no FX record required, no FX-lock segment written) and proceed to step 4 below. +2. Load the frozen `FxPolicyRecord`; missing with billing ≠ price ⇒ fail closed. +3. Convert at full precision: rate-lock ⇒ the locked rate, final at event time; invoice-period ⇒ the locked/spot rate from the frozen table, amount **flagged provisional**. +4. Apply `settlementCurrency = billing` coupons on the billing-currency amount via the slice-06 evaluator, same `fxTableVersion` (§4.4). +5. Record `fxTableVersion` / locked-rate id in metadata; `FxLockRecorder` writes the S1 segment; hand to step 9 (`EmissionGuard`). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-close-delta-fx` + +**Invoice-period FX delta at period close** (boundary with slice 08): + +1. Billing closes the period; the close-time `fxTableVersion` becomes authoritative ([`../PRD.md`](../PRD.md) §6.9). +2. The slice-08 wrapper re-rates each provisional line via `FxCloseDeltaCalculator`: identical frozen tuple and pinned snapshot, only the close-time `fxTableVersion` substituted — a **full re-execution of step 8 plus the billing-currency coupon pass and the step-9 guards**, diffed at the line level. (A `percent` coupon re-scales with the converted amount; a billing-currency `fixed_amount` coupon does not — recomputing the conversion alone would mis-state the delta.) +3. The difference leaves as a delta keyed `(window[, slice], prior-rated-version, snapshot)` via the Adjustment path ([`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md)); the provisional line is never mutated. +4. Replay of either stage is byte-identical given which `fxTableVersion` applied at which stage — both are recorded inputs. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-fx` + +**None owned.** FX tables and lock policies live in Finance; the recorded `fxTableVersion` / +locked-rate id and the FX-lock segment ride the emitted outcome (Rating persistence). The only local +state is a non-authoritative cache of frozen table pages keyed by `fxTableVersion`, whose loss +degrades latency, never correctness. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-fx` + +Runs in the `rating` gear (rating-core crate) ([`./01-foundation.md`](./01-foundation.md) +§3.8) — no additional topology. FX table pages are pinned per version and safely cold-startable; +there is no shared mutable rate state and no cross-partition coordination. + +## 4. Additional Context + +### 4.1 Currency Role Separation (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-currency-roles-fx` + +- **Price currency** — the currency of the `Price.amount` row selected at step 2. Per-market rows are first-class catalog rows authored per `(currency, region)` in the pricing gear; **no FX derivation ever** — a missing market row is absent, not derivable (pricing [`design/04`](../../../pricing/docs/design/04-currency-tax.md); `currencyFallbackPolicy` is a pricing Future). +- **Billing currency** — the invoice currency per payer account/contract, delivered via the Subscriptions-frozen `(currency, region)` binding (S1). +- **Presentment currency** — portal display FX; non-authoritative, outside rating-core, labelled estimates. +- Conversion applies **iff** billing ≠ price; equal currencies skip step 8 (native multi-currency, §17.1 step 2). +- Single-currency-per-invoice is a pricing publish-time guarantee (pricing Slice 4 `CurrencyBindingChecker`); Rating relies on it and never mixes currencies within one line. + +### 4.2 FX Policy Semantics (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-fx-policy-fx` + +- Exactly two deterministic policies, selected by the frozen Finance policy ([`../PRD.md`](../PRD.md) §6.9): **(a) per-window rate-lock** — the locked rate is final at event time; the conversion is definitive and the lock id is recorded; **(b) invoice-period FX** — provisional amount at the locked/spot rate on the hot path (flagged provisional), authoritative re-rate at the close-time `fxTableVersion`, emitted **as a delta** under the slice-08 correction keys (§3.6). +- `fxTableVersion` is part of the determinism tuple ([`./01-foundation.md`](./01-foundation.md) §4.2): replay over identical inputs — including which version applied at which stage — is byte-identical. +- Missing FX record (table, policy, or lock) with billing ≠ price ⇒ **fail closed**; no implicit or provider-default rate exists in the design. +- The frozen table MUST carry the exact **pair-direction** record (price → billing) for the conversion; rate inversion or cross-rate derivation from other pairs is a derivation Rating never performs — absence of the exact pair fails closed. +- The policy-binding scope (which payer/account/contract binds which policy) arrives frozen from Finance; its concrete field shape is closed in [`11-consumer-contracts.md`](./11-consumer-contracts.md), not here. + +### 4.3 FX-Lock Snapshot Segment (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-fx-lock-segment-fx` + +- The FX-lock id (if any) is a **Rating-written segment** of `pricingSnapshotRef`, appended at evaluation and sealed by the `SnapshotComposer` (SEAMS S1; [`./01-foundation.md`](./01-foundation.md) §4.3). +- Segment population: rate-lock ⇒ the lock id; invoice-period ⇒ no lock id — the applied `fxTableVersion` + provisional flag ride the outcome metadata; native (step 8 skipped) ⇒ segment empty. +- The sealed ref is immutable: the close-time re-rate never rewrites it — the close delta carries its own correction key and records the close-time `fxTableVersion` in its own metadata. + +### 4.4 Ordering and Precision at the FX Boundary (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-fx-ordering-fx` + +- **Order**: coupons with `settlementCurrency = price` complete in step 7 **before** conversion; conversion runs once; coupons with `settlementCurrency = billing` apply **after** conversion on the billing-currency amount under the **same** `fxTableVersion`; then the step-9 guards. No other step converts ([`../PRD.md`](../PRD.md) §17.1 steps 7–8, §17.2; [`./01-foundation.md`](./01-foundation.md) §3.6). +- **Native-currency lines**: only the conversion is skipped — the billing-currency coupon pass still executes in its step-8 position on the (identical-currency) amount, keeping coupon placement invariant. The PRD states only the FX skip (§17.1 step 2); this placement reading is fixed here in Design and exercised by the slice-06 joint coupon fixture. +- **Precision**: conversion computes at full intermediate precision and never rounds; Billing rounds in billing currency after conversion, and the emission records the rounding-policy id ([`./01-foundation.md`](./01-foundation.md) §4.4). The non-negative guard runs at step 9, after the billing-currency coupon pass. +- **Period floor/cap**: amounts set in price currency convert for billing-currency comparison with the same FX policy + `fxTableVersion` as step 8 (§17.2) — executed under slice 09's obligation, with this slice's recorded policy identity. + +## 5. Traceability + +- **PRD**: §6.9 (`fr-multi-currency`, `fr-fx-policy`), §17.1 step 8 + step-2 native skip + "Multi-currency (preserved)", §17.2 (billing-currency coupons; floor/cap currency), §12 AC 8, §4.1, §9.2 (Finance FX input contract), §7.1 NFRs. +- **Seams**: S1 (fx-lock segment — the Rating-written segment owned by this slice), W2 (the close delta replays the pinned snapshot) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-03 (snapshot composition), T-D-04 (snapshot-only replay for the close delta) — [`../DECISIONS.md`](../DECISIONS.md). +- **Slices**: [`01-foundation.md`](./01-foundation.md) (pipeline slot, determinism tuple, emission guards), [`06-coupons.md`](./06-coupons.md) (coupon ordering across the FX boundary), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (close-time delta path), [`09-period-plan-change.md`](./09-period-plan-change.md) (floor/cap conversion), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (Finance/Billing contracts). +- **Pricing design set**: [`04-currency-tax.md`](../../../pricing/docs/design/04-currency-tax.md) (per-market rows, no FX derivation, currency binding), [`06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md) (frozen read-model consumer contract). diff --git a/gears/bss/rating/docs/design/08-retroactivity-corrections.md b/gears/bss/rating/docs/design/08-retroactivity-corrections.md new file mode 100644 index 000000000..4b72e4029 --- /dev/null +++ b/gears/bss/rating/docs/design/08-retroactivity-corrections.md @@ -0,0 +1,306 @@ + + + +# DESIGN — Retroactivity & Corrections (Slice 8) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-retroactivity-corrections` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Snapshot-Only Replay (normative)](#41-snapshot-only-replay-normative) + - [4.2 Correction Key and Delta Idempotency (normative)](#42-correction-key-and-delta-idempotency-normative) + - [4.3 periodState Routing (normative)](#43-periodstate-routing-normative) + - [4.4 Reversal Math and Emission Guards (normative)](#44-reversal-math-and-emission-guards-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +Retroactivity is **not a step**: this slice is the correction/replay surface that **wraps** the +per-line §17.1 flow ([`./01-foundation.md`](./01-foundation.md) §3.4) — late-arriving usage, +correcting/negative usage, the slice-07 FX close re-rate, and plan-change portion corrections. A +correction is **the same pure pipeline, run again**: replay steps 1–9 over the **snapshot pinned at +first rating of that window** with corrected frozen inputs (an administrative re-rate substitutes +the superseding snapshot — §4.1), diff against the prior rated version, and +emit **only deltas** under the correction key `(window[, slice], prior-rated-version, snapshot)`. A live catalog +read on a correction path is *a defect by definition* (SEAMS W2), identically for open and posted +periods; pricing guarantees snapshot retention for open windows with no read-model change. + +`periodState` routes the destination, never the math: `open` re-resolves in place (deltas for +already-rated events); `closed_posted` sends the same diff through posted-period protection — delta +adjustments via the Adjustment path, posted invoices immutable; missing `periodState` fails closed. +Reversal is diff-derived: Rating re-materializes `Q` before replay, the re-run waterfall yields the +pool refill, and no resolved line is ever driven negative. The wrapper is side-effect-free: Rating +never mutates Usage, counters, balances, or posted financials — the owners apply the effects ([`../PRD.md`](../PRD.md) §6.10, §6.1). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-posted-period-protection` | `periodState = closed_posted` ⇒ the `PostedPeriodGuard` routes the diff to delta-only Adjustment output (§4.3); posted invoice lines and prior rated outputs are never mutated; every retro delta carries the bitemporal stamps — usage-observation time and pricing-policy decision time (§4.4). | +| `cpt-cf-bss-rating-fr-late-arriving-usage-reresolve` | For `tierAggregationWindow != per_event` with `periodState = open`, the `ReplayCoordinator` re-runs the **whole window unit** strictly from the pinned snapshot after Rating re-materializes `Q`; the `DiffEngine` emits deltas for already-rated events only (§4.1, §4.3); missing `periodState` fails closed. | +| `cpt-cf-bss-rating-fr-usage-corrections` | A correcting/negative usage event reverses deterministically: replay over the corrected `Q` re-places tiers and re-runs the step-6 waterfall — the drawdown difference *is* the pool refill; compensating deltas are emitted and no resolved line goes negative (§4.4). Correction ingestion and dedup remain Rating's. | +| `cpt-cf-bss-rating-fr-idempotency` (delta family) | Every delta carries the correction key `(window[, slice], prior-rated-version, snapshot)` ([`./01-foundation.md`](./01-foundation.md) §4.2); a retry replays to the byte-identical delta set and never double-adjusts; the delta-dedup **owner is Rating** (§2.2, T-D-11). | +| `cpt-cf-bss-rating-fr-separation` | The wrapper is side-effect-free: outcomes leave as deltas via the Adjustment path; Usage is never mutated; balance/counter effects are applied by their owners (Contracts, Rating). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partition-key serialization | Concurrent re-resolve for one unit serializes on `(subscription, meter, dimensionKey, window)`; unrelated windows replay in parallel; zero cross-partition locks | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Correction key + fail-closed routing | Retries replay the same key to the same deltas; missing `periodState` / pinned snapshot / prior rated version ⇒ fail closed, never a guess | Chaos/retry test | +| `cpt-cf-bss-rating-nfr-throughput-latency` | Off-hot-path corrections | Replay runs on the correction path (batch / Adjustment), never blocking first rating; the hot path is untouched | Load test; **targets provisional — NFR workshop** ([`../PRD.md`](../PRD.md) §7.1) | +| Bitemporal correction audit (§6.10) | Delta envelope | Usage-observation time + pricing-policy decision time stamped separately on every retro delta; persisted by the delta's consumer (rating-core stores nothing — §3.7) | Joint fixture | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Replay selects on the same adopted 8-axis key resolved from the pinned snapshot — a correction can never resolve a different row than first rating did over identical inputs. | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (adopted) | `PriceWindow*` events only invalidate the non-authoritative cache; corrections never consult live window state — expired/cancelled windows are immutable pricing history and the pin is the only read ([`../../../pricing/docs/design/07-pricewindow-linkage.md`](../../../pricing/docs/design/07-pricewindow-linkage.md)). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-rtr` + +```text +Correction wrapper (this slice) PostedPeriodGuard · ReplayCoordinator · DiffEngine · + │ (wraps §17.1 steps 1–9; ReversalCalculator · BitemporalStamper + ▼ no step slot of its own) +Evaluation pipeline (Foundation) reresolve over the pinned snapshot · DeterminismGuard + │ (correction keys, serialization) · EmissionGuard + ▼ +Frozen inputs (external SoRs) re-materialized Q + prior rated versions (Rating) · pinned + snapshot, retained (pricing) · periodState (Billing) · frozen pool set (Contracts) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The correction wrapper around the per-line flow; replay triggers from Rating / period close | Rust module in the `rating` gear (rating-core crate) | +| Domain | Correction key, delta envelope, reversal effects, `periodState` routing | Rust; GTS + Rust domain structs | +| Infrastructure | **None authoritative** — corrections read the pin, not the cache; deltas persist with their consumer (Rating/Billing) | Rating persistence | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Replay the pin, never the catalog + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-pinned-replay-rtr` + +Every correction replays the snapshot pinned at first rating of that window — never a live catalog +read; a live read on a correction path is a defect by definition (SEAMS W2), for open and posted periods alike. + +#### Deltas out, never mutation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-delta-only-rtr` + +The output of every correction is a set of compensating deltas via the Adjustment path; prior rated +outputs and posted invoices are immutable diff inputs, never write targets ([`../PRD.md`](../PRD.md) §6.10, §6.1 `fr-separation`). + +#### One math, run twice + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-same-math-rtr` + +A correction is the unmodified §17.1 pipeline over corrected frozen inputs — no special-cased retro +formulas. The delta is *defined* as (re-resolved outcome − prior rated version): reversal is deterministic by construction and replay byte-identical ([`./01-foundation.md`](./01-foundation.md) §4.2). + +### 2.2 Constraints + +#### Posted-period immutability + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-posted-immutability-rtr` + +`periodState = closed_posted` ⇒ delta-only corrections via the Adjustment path; posted invoice lines +and prior rated output are never mutated ([`../DESIGN.md`](../DESIGN.md) §2.2 `cpt-cf-bss-rating-constraint-posted-immutability`; [`../PRD.md`](../PRD.md) §6.10). + +#### periodState is a required input + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-periodstate-required-rtr` + +Billing is the only source of `periodState`; a correction with `periodState` missing fails closed — no guessing ([`../PRD.md`](../PRD.md) §6.10). + +#### Delta dedup owner: Rating + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-delta-dedup-owner-rtr` + +The correction key makes deltas idempotent; the **owner of delta dedup is Rating** (**decided +2026-07-11, T-D-11** — the Foundation decision, restated here where the deltas are produced: +[`./01-foundation.md`](./01-foundation.md) §2.2; [`../PRD.md`](../PRD.md) §6.1). Rating persists +delta outcomes keyed by the correction key; a retried delta with a known key returns the recorded +outcome and is never re-emitted downstream; Billing additionally treats Adjustment consumption as +idempotent on the same key (defense in depth). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-rtr` + +- **`CorrectionKey`** — `(window[, slice], prior-rated-version, snapshot)` ([`./01-foundation.md`](./01-foundation.md) §4.2) — the slice coordinate present iff a slice-09 split partitions the window; the identity of every delta. +- **`CorrectionTrigger`** — what entered the wrapper: late usage, correcting/negative usage, FX close re-rate ([`07-currency-fx.md`](./07-currency-fx.md) §3.6), a plan-change portion correction ([`09-period-plan-change.md`](./09-period-plan-change.md)), or an **administrative re-rate** (§4.1 — the one trigger that substitutes a superseding snapshot); all take the same replay path. +- **`PriorRatedVersion`** — frozen input: the rated output version being corrected (Rating persistence). +- **`PeriodState`** — frozen Billing input (`open` \| `closed_posted`); the routing key (§4.3). +- **`DeltaAdjustment`** — the emitted envelope: correction key, signed full-precision per-line amount deltas, discount/FX lineage, reversal effects, bitemporal stamps (`usageObservedAt`, `policyDecidedAt`), and the route taken. +- **`ReversalEffect`** — diff-derived effects of a reversal: per-pool commitment refill (Contracts SoR applies to its balances) and the tier-counter decrement already realized in the re-materialized `Q` (Rating single-writer). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-correction-wrapper-rtr` + +- **`ReplayCoordinator`** — pins `(window, snapshot)`, acquires per-partition serialization, requires Rating's re-materialized `Q`, and invokes the Foundation `reresolve`; fails closed on a missing pin or prior version. +- **`PostedPeriodGuard`** — reads `periodState` and routes (§4.3): `open` ⇒ in-place re-resolution deltas; `closed_posted` ⇒ the same diff through posted-period protection; missing ⇒ fail closed. +- **`DiffEngine`** — computes (re-resolved − prior rated version) per line at full precision; an empty diff emits nothing (idempotent replay). +- **`ReversalCalculator`** — derives `ReversalEffect`s from the step-3/step-6 diffs (tier re-placement, waterfall drawdown difference = refill) and enforces the non-negative guard on every corrected line (§4.4). +- **`BitemporalStamper`** — stamps usage-observation and pricing-policy decision time on every delta (via the Foundation `MetadataRecorder` envelope). + +### 3.3 API Contracts + +This slice **implements** the Foundation re-resolution contract +`cpt-cf-bss-rating-interface-reresolve-fnd`: `reresolve(window, pinnedSnapshotRef, priorRatedVersion) +→ deltas` — strictly snapshot-replayed, serialized on the partition key, emitting only deltas +([`./01-foundation.md`](./01-foundation.md) §3.3). No second entry point exists: every trigger in §3.1 funnels into it. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-delta-envelope-rtr` + +The **delta adjustment envelope**: every emitted delta carries the correction key, signed +full-precision amounts, reversal effects, discount/FX lineage, and the bitemporal stamps — +consumable by Billing per immutability rules ([`../PRD.md`](../PRD.md) §6.10). The Rating handoff +shape and the Billing `periodState`/obligation contract are owned by +[`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Foundation ([`01-foundation.md`](./01-foundation.md)): the `reresolve` contract, correction keys, +`DeterminismGuard` serialization, emission guards. Replay runs the step evaluators of slices +[`02`](./02-selection-eligibility.md)–[`07`](./07-currency-fx.md) unchanged: +[`03-metering-models.md`](./03-metering-models.md) re-places tiers over the corrected `Q`; +[`05-commitments-reservations.md`](./05-commitments-reservations.md) re-runs the waterfall whose +drawdown difference is the refill; [`06-coupons.md`](./06-coupons.md) / [`07-currency-fx.md`](./07-currency-fx.md) +replay coupons and FX — and slice 07's invoice-period close re-rate enters here as a +`CorrectionTrigger`. Plan-change corrections to an already-rated portion +([`09-period-plan-change.md`](./09-period-plan-change.md), §17.2) route through the same keys; boundary contracts: [`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Rating | re-materialized `Q` (single-writer per partition key), prior rated versions, usage dedup, correction ingestion | PRD §9.2 Rating handoff | +| Billing | `periodState` (`open` / `closed_posted`); consumes delta adjustments per immutability rules | PRD §9.2 Billing | +| Pricing (Product Catalog) | the pinned snapshot, retained for open windows (no live read — W2); window history immutable | [`../SEAMS.md`](../SEAMS.md) W2; pricing [`design/07`](../../../pricing/docs/design/07-pricewindow-linkage.md) | +| Contracts | the commitment pool set, balances, and draw order frozen in `pricingSnapshotRef` (§17.1 step 6); balance SoR applying refill effects | PRD §6.6, §6.10 | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-open-period-correction-rtr` + +**Open-period late arrival** (extends `cpt-cf-bss-rating-flow-reresolve-open-window-fnd`): + +1. Late/corrected usage lands in Rating (ingestion + dedup are Rating's); the window's `Q` is re-materialized (single-writer). +2. `ReplayCoordinator` serializes on `(subscription, meter, dimensionKey, window)`; loads the pinned snapshot ref and prior rated version; either missing ⇒ fail closed. +3. Foundation `reresolve` replays steps 1–9 for the **whole window unit** over the pinned snapshot — no live catalog read. +4. `DiffEngine` emits per-line deltas keyed `(window[, slice], prior-rated-version, snapshot)` for already-rated events; an empty diff emits nothing. +5. `BitemporalStamper` + `EmissionGuard`; deltas leave via the Adjustment path. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-posted-period-correction-rtr` + +**Posted-period correction** (same math, protected route): + +1. Steps 1–4 of the open-period flow run identically — `periodState` never changes the math (§4.3). +2. `PostedPeriodGuard` routes the diff through posted-period protection: delta adjustments consumable by Billing per immutability rules; posted invoice lines and prior rated output untouched. +3. Every delta records usage-observation time and pricing-policy decision time separately ([`../PRD.md`](../PRD.md) §6.10). +4. A retry replays the same correction key byte-identically; double-adjust is excluded by delta dedup (owner Rating — §2.2, T-D-11). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-negative-usage-reversal-rtr` + +**Correcting / negative usage reversal**: + +1. The correcting event passes Rating ingestion/dedup; Rating re-materializes `Q` for the partition key (the tier-counter decrement is realized here — Rating never writes a counter). +2. Replay over the corrected `Q`: tier placement re-resolves (slice 03); the step-6 waterfall re-runs over the snapshot-frozen pool set (slice 05) — the drawdown difference is the commitment-pool refill, applied by Contracts to its balances. +3. `ReversalCalculator` verifies no **resolved corrected line** is negative ([`./01-foundation.md`](./01-foundation.md) §4.4); compensating deltas themselves are signed and may be negative-valued. +4. `periodState` routes the result: `open` ⇒ in-place deltas; `closed_posted` ⇒ the posted-period flow above. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-rtr` + +**None owned.** Prior rated versions, `Q`, and dedup state live in Rating; snapshot retention is the +pricing gear's W2 obligation; pool balances live in Contracts; `periodState` in Billing. The bitemporal +audit stamps ride the delta envelope into the consumer's persistence — rating-core holds no correction +ledger. Corrections bypass the Foundation's resolved-window cache: they read the pin. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-rtr` + +Runs in the `rating` gear (rating-core crate) ([`./01-foundation.md`](./01-foundation.md) §3.8) +— no additional topology. The correction path is off the hot path (batch / Adjustment), serialized per partition key, and safely restartable: a crashed correction replays the same key. + +## 4. Additional Context + +### 4.1 Snapshot-Only Replay (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-snapshot-replay-rtr` + +- The replay source is **the snapshot pinned at first rating of that window** — for open-period late arrival **and** posted-period corrections alike. A live catalog read on a correction path is a defect by definition (SEAMS W2, resolved 2026-07-10; [`../PRD.md`](../PRD.md) §6.10). +- Pricing guarantees **snapshot retention for open windows** and changes nothing in its read model; the rejected alternative — a pricing historical-window query surface — is recorded in [`../SEAMS.md`](../SEAMS.md) (more cost, weaker determinism). +- `fxTableVersion` at replay: which version applied at which stage is part of the replay inputs ([`../PRD.md`](../PRD.md) §6.9). The FX close re-rate substitutes **only** the close-time version ([`07-currency-fx.md`](./07-currency-fx.md) §4.2); every other frozen input is identical. +- **Administrative re-rate (the sanctioned snapshot substitution)**: an administrative repricing ([`../PRD.md`](../PRD.md) §6.10 — a *policy* decision, not an input correction) replays over the **superseding snapshot** published through the pricing governance pipeline (corrective publish / historical import — always-material, two-person on the pricing side); the diff still runs against the prior rated version, the correction key carries the **new** snapshot (the key shape already admits it), and `policyDecidedAt` is the corrective-publish instant. Input corrections replay the original pin; policy corrections replay the superseding pin — never a live read in either case. +- **Retention horizon**: pinned snapshots MUST remain resolvable for the full correction horizon (posted-period corrections arrive years later) — satisfied by the pricing gear's append-only in-table history + monotonic read-model versions (≥ the 7-year audit retention). + +### 4.2 Correction Key and Delta Idempotency (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-correction-key-rtr` + +- Every delta carries the correction key `(window[, slice], prior-rated-version, snapshot)` ([`./01-foundation.md`](./01-foundation.md) §4.2): a retry **replays** — same key, byte-identical delta set — and never double-adjusts. +- A subsequent correction to the same window diffs against the then-current prior rated version, producing a **new** key: keys never repeat across distinct corrections, and the chain of keys is the window's correction lineage. +- The key makes dedup *possible*; the dedup **owner is Rating** (§2.2, T-D-11) — enforcement lives with the delta persister. + +### 4.3 periodState Routing (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-periodstate-routing-rtr` + +- `open` ⇒ re-resolve in place: deltas for already-rated events; the re-resolved window outcome stands for subsequent rating in the window ([`../PRD.md`](../PRD.md) §6.10). +- `closed_posted` ⇒ the **same diff** through posted-period protection: delta adjustments via the Adjustment path; posted invoice lines immutable ([`./01-foundation.md`](./01-foundation.md) §3.6 step 4). +- missing ⇒ **fail closed**, no guessing. +- Routing decides the destination and audit posture only — never the math (§2.1, one math run twice). + +### 4.4 Reversal Math and Emission Guards (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-reversal-guards-rtr` + +- **Reversal is diff-derived**: the commitment-pool refill is (prior drawdown − re-resolved drawdown) per pool, in the waterfall order frozen in `pricingSnapshotRef`; the tier-counter decrement is realized in the re-materialized `Q` before replay (Rating single-writer, key `(subscription, meter, dimensionKey, window)` — SEAMS M7). Rating computes effects; owners apply them (Contracts balances, Rating counters). +- **Cascades (T-D-10, T-D-12)**: two frozen inputs couple evaluation units, and correcting one unit re-resolves its dependents as ordinary deltas, each under its **own** pin: (a) a balance-affecting correction changes later units' frozen pool balances — Contracts (the `balanceVersion` serializer) emits re-resolution triggers for every later unit that drew, or was rated overage against, the affected pool ([`05-commitments-reservations.md`](./05-commitments-reservations.md) §4.1); (b) an earlier sub-window slice's `Q` change shifts later slices' `bandOffsetQ` — Rating re-materializes the offsets and the later slices of the same aggregation window re-resolve ([`03-metering-models.md`](./03-metering-models.md) §4.3). Cascade deltas carry their own correction keys; termination is structural — both chains are finite and strictly ordered (balance versions / slice order). +- **Non-negative**: no resolved corrected line goes negative ([`./01-foundation.md`](./01-foundation.md) §4.4); the guard applies to resolved line outcomes, not to delta signs — compensating deltas are legitimately negative-valued. The clamp-vs-credit residue policy remains the PRD §15 open (Finance). +- **Bitemporal audit**: usage-observation time and pricing-policy decision time are recorded **separately** on every retro delta ([`../PRD.md`](../PRD.md) §6.10). +- **Full precision**: deltas leave at full intermediate precision; Billing aggregates and rounds ([`./01-foundation.md`](./01-foundation.md) §4.4). + +## 5. Traceability + +- **PRD**: §6.10 (`fr-posted-period-protection`, `fr-late-arriving-usage-reresolve`, `fr-usage-corrections`), §6.1 (`fr-idempotency` delta family, `fr-separation`), §17.1 "Determinism and Rating compatibility", §12 AC 9–10, §15 (clamp-vs-credit open), §9.2 (Rating / Billing contracts), §7.1 NFRs. +- **Seams**: W2 (owned — snapshot-only replay), M7 (counter key), S1 (the pinned ref is the replay source) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-04 (primary — pinned-snapshot replay + counter key), T-D-03 (snapshot composition), T-D-10/T-D-12 (cascade re-resolution), T-D-11 (delta dedup: Rating) — [`../DECISIONS.md`](../DECISIONS.md). +- **Slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, `reresolve` interface, correction keys, guards), [`03-metering-models.md`](./03-metering-models.md) (tier re-placement), [`05-commitments-reservations.md`](./05-commitments-reservations.md) (pool refill semantics), [`06-coupons.md`](./06-coupons.md) / [`07-currency-fx.md`](./07-currency-fx.md) (coupon/FX replay; FX close delta), [`09-period-plan-change.md`](./09-period-plan-change.md) (plan-change correction deltas, period obligations), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (Rating/Billing boundary). +- **Pricing design set**: [`07-pricewindow-linkage.md`](../../../pricing/docs/design/07-pricewindow-linkage.md) (immutable window history, `PriceWindow*` events), [`06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md) (frozen read-model consumer contract). diff --git a/gears/bss/rating/docs/design/09-period-plan-change.md b/gears/bss/rating/docs/design/09-period-plan-change.md new file mode 100644 index 000000000..2793792c4 --- /dev/null +++ b/gears/bss/rating/docs/design/09-period-plan-change.md @@ -0,0 +1,308 @@ + + + +# DESIGN — Period & Plan-Change Obligations (Slice 9) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-period-plan-change` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Adopted Proration and Anchor Enums (normative)](#41-adopted-proration-and-anchor-enums-normative) + - [4.2 PeriodFloorCapObligation Envelope (normative)](#42-periodfloorcapobligation-envelope-normative) + - [4.3 Sub-Window Split Semantics (normative)](#43-sub-window-split-semantics-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice owns the two **period-level** concerns that live *outside* the per-line §17.1 step +order — steps 1–9 have no slot for them ([`../PRD.md`](../PRD.md) §6.11, §17.1 period-level +note); together with slice 08 it "wraps the per-line flow" +([`01-foundation.md`](./01-foundation.md) §3.4). Concern one is the +**`PeriodFloorCapObligation`**: Rating resolves the floor/cap amount, currency, comparison +basis, and attachment scope from the pinned snapshot and *surfaces* a structured obligation; +**Billing executes** `max(total, floor)` / `min(total, cap)` during period aggregation, and +Billing rounds — Rating never applies either ([`01-foundation.md`](./01-foundation.md) §4.4). +Concern two is **sub-window proration**: when a `PriceWindow` activates mid-period, a plan +changes at `changeEffectiveAt`, or a **phase converts mid-period** (the third boundary kind — +§4.3), the invoice period splits into half-open UTC slices, each rated +as an ordinary per-line run over its *own* pinned snapshot and plan revision, with the +recurring component apportioned per the snapshot-frozen `prorationBasis`. + +The slice introduces **no policy of its own**: the `prorationBasis` and `billingAnchorPolicy` +enums are the pricing gear's, adopted verbatim under a live CI gate (seams P1/P2, +[`../SEAMS.md`](../SEAMS.md)); the plan-change mode is Subscriptions' — Rating consumes +`(changeEffectiveAt, changeMode)` and **never decides the change**. What the slice owns is the +deterministic *math*: the anchor-boundary calendar (D-20 no-drift clamp), the split geometry, +the proration fractions, and the obligation shape. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-period-floor-cap-obligation` | The `PeriodObligationComposer` resolves amount, currency, comparison basis, and attachment scope from the pinned snapshot and emits a `PeriodFloorCapObligation` on the outcome envelope (§4.2); Billing executes and rounds; the per-line non-negative guard has already run — a floor never masks a negative line. | +| `cpt-cf-bss-rating-fr-mid-cycle-proration` | The `ProrationSplitter` cuts the invoice period at the activating window's `effectiveFrom` into `SubWindowSlice`s, each with its own snapshot, each emitted at full precision (§4.3); prorated recurring components use the frozen `prorationBasis`; Billing aggregates and rounds (AC 7). | +| `cpt-cf-bss-rating-fr-plan-change-proration` | planA rates over `[periodStart, changeEffectiveAt)`, planB over `[changeEffectiveAt, periodEnd)` — half-open, UTC, each against its own revision and snapshot (§4.3); tier-`Q`/pool carry-vs-reset follows snapshot-frozen flags; corrections to already-rated portions leave as deltas under the slice-08 correction key; `(changeEffectiveAt, changeMode)` is consumed, never decided. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | `ProrationSplitter` | A split multiplies per-line runs only by the slice count, bounded by the window activations + plan changes inside one period (normally 0–1); each slice is an ordinary pipeline run over a pre-pinned snapshot — no new I/O | Load test with split fixtures | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partitioning contract | Splits and period obligations stay inside the `(subscription, meter, dimensionKey, window)` partition; anchor math is context-local; zero cross-partition locks | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Fail-closed guards | Missing `periodState` fails closed (01 §2.1); a recurring row cannot lack `prorationBasis`/`billingAnchorPolicy` (pricing publish rejects it), but an absent frozen value still fails closed, never defaults | Chaos/retry test + conformance fixtures | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Every sub-window slice re-selects its base row on the adopted 8-axis key — a split never bypasses full-key selection. | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (adopted) | The pricing gear owns the `PriceWindow` store and activation; the mid-cycle split boundary is the pinned window's `effectiveFrom`, never a Rating-scheduled event. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-ppc` + +```text +Period phases (this slice) AnchorCalendar · ProrationSplitter · PeriodObligationComposer + │ (compose per-line runs; surface obligations) + ▼ +Per-line pipeline (01, 02–07) one ordinary evaluate() per SubWindowSlice + │ + ▼ +Frozen inputs (external SoRs) prorationBasis + billingAnchorPolicy + carry-vs-reset flags + (pricing snapshot) · (changeEffectiveAt, changeMode) + (Subscriptions) · periodState (Billing) · windowed Q (Rating) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Period wrappers around the per-line flow: split composition, obligation emission | Rust modules in the `rating` gear (rating-core crate), per 01 §3.8 | +| Domain | Anchor/D-20 calendar math, proration fractions, split geometry, obligation shape | Rust; GTS + Rust domain structs | +| Infrastructure | **None owned** — obligations and slice outcomes ride the Rating handoff; execution state is Billing's | Rating persistence; Billing period aggregation | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Surface, never execute + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-surface-not-execute-ppc` + +Period-level floor/cap is emitted as a structured obligation and *executed by Billing* during +period aggregation. Rating never applies `max`/`min` over a period total, never rounds, and +never posts — obligations are surfaced, corrections to them flow as deltas (§4.2). + +#### Split, never blend + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-split-never-blend-ppc` + +A period containing a boundary is rated as discrete half-open sub-windows, each over its own +pinned snapshot and revision at full precision. There is no rate averaging, no blended price, +and no cross-slice reuse of a stale snapshot; Billing sums the slices (§4.3). + +#### Consume the change, never decide it + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-consume-change-ppc` + +`(changeEffectiveAt, changeMode)` arrives from Subscriptions, which owns WHEN a plan change +takes effect and the up/down asymmetry policy ([`../PRD.md`](../PRD.md) §17.2). The mode's only +evaluation-side effect is where the split boundary falls; Rating holds no change policy. + +### 2.2 Constraints + +#### No rounding, no period min/max in rating-core + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-round-no-execute-ppc` + +rating-core MUST NOT apply period-level min/max at line aggregation and MUST NOT round; amounts leave +at full intermediate precision with the rounding-policy id recorded +([`../PRD.md`](../PRD.md) §6.11; 01 §4.4). The invoice-period total is Billing's sum of +sub-window amounts followed by Billing's rounding policy (AC 7). + +#### Adopted enums, verbatim, CI-gated + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-enum-verbatim-ppc` + +`prorationBasis` (including `none`) and `billingAnchorPolicy` are adopted verbatim from the +pricing gear and guarded by the CI gate `pricing.contracts.enum_drift` (Critical) — any local +extension or omission is a build-time block (seams P1/P2; T-D-07). + +#### UTC half-open boundaries only + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-utc-half-open-ppc` + +All period boundaries, split points, and anchor math are UTC with half-open `[from, to)` +intervals (01 §2.2 `constraint-utc-money-fnd`; D-20 anchor math is UTC by definition). A +boundary instant belongs to exactly one slice. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-ppc` + +- **`PeriodFloorCapObligation`** — the value object this slice shapes (emission envelope owned by the Foundation, 01 §3.1): floor/cap amount, explicit currency, comparison basis, attachment scope (`usage` default, `recurring+usage` if plan-level — snapshot-frozen), period reference, contract/plan reference (§4.2). +- **`AnchorPeriod`** — the `[periodStart, periodEnd)` UTC boundaries implied by the frozen `billingAnchorPolicy` under the D-20 no-drift clamp; the authority for `invoice_period` boundaries and plan-change split points (seam P2). +- **`SubWindowSlice`** — one half-open `[from, to)` UTC slice of an `AnchorPeriod` bound to its own pinned snapshot and plan revision; the unit the per-line pipeline runs over during a split. +- **`ProrationFraction`** — the deterministic apportionment of a recurring amount to a slice per the frozen `prorationBasis` value (§4.1, §4.3); full-precision, never rounded here. +- **Frozen inputs** — `prorationBasis`, `billingAnchorPolicy`, tier-`Q`/pool carry-vs-reset flags (pricing snapshot); `(changeEffectiveAt, changeMode)` (Subscriptions); `periodState` (Billing). None owned, all fail-closed when absent. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-period-plan-change-ppc` + +- **`AnchorCalendar`** — computes `AnchorPeriod` boundaries and locates split points from the frozen `billingAnchorPolicy`, applying the D-20 clamp (day beyond month length → last day of month, anchor day preserved per period: 31→28→31, no drift); pure UTC calendar math, no I/O. +- **`ProrationSplitter`** — cuts the enclosing `AnchorPeriod` at each boundary (window `effectiveFrom`, `changeEffectiveAt`, phase-conversion instants) into ordered `SubWindowSlice`s; assembles one evaluation context per slice; applies `ProrationFraction` to recurring components; routes the boundary-continuity inputs to the tier counter (slice 03) and commitment pools (slice 05): mandatory carry (`bandOffsetQ` continuity) at window-activation and phase-conversion boundaries, the snapshot-frozen carry-vs-reset flag at a plan-change boundary (§4.3, T-D-12). +- **`PeriodObligationComposer`** — resolves the floor/cap attachment from the pinned snapshot and builds the `PeriodFloorCapObligation`; hands it to the Foundation's emission envelope; records it in evaluation metadata. + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-period-obligation-ppc` + +The **obligation ride-along**: `PeriodFloorCapObligation` travels on the Rating handoff next to +the resolved outcome ([`../PRD.md`](../PRD.md) §9.2 Rating handoff / Billing `periodState` +contract). It is advisory-to-execute: Billing executes it at period aggregation; Rating holds +no execution state and receives no execution result. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-split-evaluation-ppc` + +The **split contract** (conceptual, in-process): `split(AnchorPeriod, boundaries) → ordered +SubWindowSlice contexts`, each fed through the Foundation `evaluate()` unchanged. Replay of the +same frozen tuple yields byte-identical slices and amounts (01 §4.2); corrections to an +already-rated slice re-enter via `reresolve` under the correction key, never through a fresh +split of live state. + +External boundary contracts (Billing, Subscriptions, pricing read model) are owned by +[`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation.md`](./01-foundation.md) for the pipeline, guards, determinism keys, +and the obligation emission envelope. Each `SubWindowSlice` is a full 02–07 per-line run; +slices 03/05 consume the carry-vs-reset flags this slice routes at a plan-change boundary; +slice 07's FX policy converts a price-currency floor/cap for billing-currency comparison. +Delta/correction traffic for period obligations and already-rated slice portions flows under +[`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) keys — this slice never +mints its own correction path. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing (Product Catalog) | `prorationBasis` (incl. `none`), `billingAnchorPolicy` + D-20 clamp, floor/cap attachment, carry-vs-reset flags — all in the pinned snapshot; window `effectiveFrom` boundaries | [`../../../pricing/docs/design/06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md); SEAMS P1/P2 | +| Subscriptions | `(changeEffectiveAt, changeMode)`; plan revision linkage | [`../PRD.md`](../PRD.md) §9.2 Subscriptions input | +| Billing | `periodState` (open / closed_posted); executes floor/cap + rounding over the period aggregate | [`../PRD.md`](../PRD.md) §9.2 Billing contract | +| Rating | windowed `Q` per sub-window (single-writer, M7 key); persists outcomes, obligations, deltas | [`../PRD.md`](../PRD.md) §9.2 Rating handoff | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-sub-window-split-ppc` + +**Sub-window split** (window activation or plan change): + +1. A boundary falls inside the enclosing `AnchorPeriod`: an activating window's `effectiveFrom` (from the pinned read model), `changeEffectiveAt` (from Subscriptions, with `changeMode`), or a phase-conversion instant (from the frozen Subscriptions phase timeline — §4.3). +2. `AnchorCalendar` fixes `[periodStart, periodEnd)` from the frozen `billingAnchorPolicy` (D-20 clamp, UTC). +3. `ProrationSplitter` cuts the period into half-open slices; each binds its own pinned snapshot — and, for a plan change, its own plan revision (planA left of the boundary, planB right). +4. Each slice runs the ordinary per-line pipeline (steps 1–9); recurring components carry the `ProrationFraction` for the frozen `prorationBasis`; a `none` row is recognized and never prorated (§4.1); usage components are not prorated — each slice rates the usage attributed to it. +5. At a plan-change boundary, tier `Q` and commitment pools carry or reset per the snapshot-frozen flags (evaluated in slices 03/05). +6. Every slice emits at full precision; Billing computes the period total as the sum of slices and applies its rounding policy; corrections to an already-rated portion leave as deltas under the slice-08 correction key. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-period-floor-cap-ppc` + +**Period floor/cap obligation**: + +1. Per-line outcomes for the period exist; the non-negative guard has already run per line (01 §4.4) — before any period-level phase. +2. `PeriodObligationComposer` resolves floor/cap amount, currency, comparison basis, and attachment scope from the pinned snapshot. +3. The `PeriodFloorCapObligation` is emitted on the outcome envelope and recorded in metadata; nothing is applied locally. +4. Billing executes `max(total, floor)` / `min(total, cap)` during period aggregation, then rounds. +5. Late usage into an open window re-runs the composition deterministically over the same frozen tuple; any change to the obligation surfaces as a delta under slice-08 keys — a posted obligation is never mutated. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-ppc` + +**None owned.** Consistent with 01 §3.7: obligations and slice outcomes are emitted values +persisted by Rating; whether and how a floor/cap was executed is Billing's state; period +identity and `periodState` are Billing's; anchor math is derived, never stored. Loss of any +local computation is repaired by deterministic replay of the frozen inputs. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-ppc` + +No new deployable, job, or scheduler **in Rating**: the period phases are rating-core / pipeline module code +in the `rating` gear (01 §3.8); the **period tick** that synthesizes the period-driven +evaluation units (recurring lines, capacity charges, true-up surfacing) at `AnchorPeriod` +boundaries is **Rating's** ([`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.1, +T-D-15). Splits and obligations evaluate inside the existing +`(subscription, meter, dimensionKey, window)` partitions — a split multiplies per-line runs by +its slice count but never crosses a partition; period-obligation composition is per +subscription/period and carries no cross-partition lock. + +## 4. Additional Context + +### 4.1 Adopted Proration and Anchor Enums (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-proration-enums-ppc` + +- `prorationBasis ∈ {calendar_days_actual, calendar_days_30, by_second, whole_unit, none}` — the pricing gear's canonical enum, adopted **verbatim** (seam P1, T-D-07); the CI gate `pricing.contracts.enum_drift` (Critical) blocks drift at build time. Value glosses per the pricing glossary: `calendar_days_30` uses a fixed 30-day month with the day count capped at 30; `whole_unit` performs no sub-period proration; `none` means no proration at all — full-period charge, no partial credit. +- **`none` recognition**: pricing rejects `creditOnDowngrade = true` with `prorationBasis = none` at publish ([`../../../pricing/docs/design/06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md)), so Rating never prorates a `none` row — but MUST recognize the value and skip proration; the conformance fixture asserts all five values. +- `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}` with the **D-20 no-drift clamp**: `fixed_day(d)` — and `subscription_start` under monthly-granular cycles — with a day beyond the month length anchors on the last day of the month, the anchor day preserved across periods (each period clamps independently: 31→28→31, no drift); all anchor math UTC. Frozen in the snapshot; this is the sole authority for `invoice_period` boundaries and plan-change split points (seam P2). +- **Joint fixtures**: the shared golden proration/anchor fixture (`calendar_days_30` capping; `fixed_day(31)` → February month-end UTC rollover) plus the mid-period PriceWindow-split boundary fixture asserting identical results on the catalog↔Rating side. +- **Open**: the `whole_unit` attribution rule on a split (which slice bears the un-prorated whole unit) is fixed by neither PRD; to be settled with the joint proration fixture before Design lock. + +### 4.2 PeriodFloorCapObligation Envelope (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-floor-cap-envelope-ppc` + +- Rating sets the floor/cap **amount, currency, comparison basis, and attachment scope** and emits the structured obligation; **Billing executes** `max(total, floor)` / `min(total, cap)` during period aggregation and rounds. rating-core MUST NOT apply the min/max at line aggregation or round ([`../PRD.md`](../PRD.md) §6.11, §17.2). +- **Attachment**: to the usage component by default, or recurring+usage if plan-level — frozen in `pricingSnapshotRef` (§17.2). +- **Currency**: set in price currency; a billing-currency comparison converts with the same FX policy and `fxTableVersion` as step 8 (slice 07); the currency is always explicit — no implicit default (§17.2). +- **Ordering**: the per-line non-negative guard (01 §4.4) runs before any period-level phase; a floor MUST NOT mask a negative line. +- **Surfaced, never posted**: the obligation is an emitted value, not a posting; re-emission for a re-opened window is deterministic over the same frozen tuple, and changes flow as deltas under the slice-08 correction key `(window[, slice], prior-rated-version, snapshot)`. +- **Phasing**: the boundary and obligation shape are defined now; floor/cap *execution* is phased Follow-on on the Billing side ([`../PRD.md`](../PRD.md) §17.4) — the contract does not change when execution lands. +- **Open** ([`../PRD.md`](../PRD.md) §15): whether a contractual floor claws back coupon discount — default proposal: the floor compares the post-coupon total; carried here unresolved. + +### 4.3 Sub-Window Split Semantics (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-split-semantics-ppc` + +- **Split points** — three kinds: an activating window's `effectiveFrom` (mid-cycle activation), `changeEffectiveAt` (plan change), and a **phase-conversion instant** from the frozen Subscriptions phase timeline (slice 02 `PhaseContext`) — `phase_id` is a key axis, so a mid-period trial→intro→evergreen conversion changes the selected row, and a recurring component spanning the conversion prorates across the boundary exactly like a window activation. Coincident boundaries collapse into one cut (the boundary instant belongs to the right-hand slice). Geometry is half-open UTC: the old window/planA covers `[periodStart, boundary)`, the new window/planB covers `[boundary, periodEnd)`; each slice binds its own plan revision and pinned snapshot and emits at full precision; Billing totals then rounds (AC 7, AC 17). +- **Recurring proration**: the slice fraction follows the frozen `prorationBasis` over `AnchorCalendar` denominators — `calendar_days_actual`: covered UTC days over actual days of the anchor period; `calendar_days_30`: the 30-day-month convention with day count capped at 30; `by_second`: covered seconds over period seconds; `whole_unit`/`none`: no fractional apportionment (§4.1). Fractions are computed at full intermediate precision and never rounded here. +- **Usage**: never prorated — usage attributes to a slice by event/window time; the windowed `Q` stays Rating-owned under the M7 key. When a split point falls **inside an open aggregation window**, each slice is its own evaluation unit under the band-offset continuity of [`03-metering-models.md`](./03-metering-models.md) §4.3 (T-D-12): window-activation/supersession and phase-conversion boundaries **always carry** the counter (pricing `inst-tb-window-continuity` — not configurable); only a *plan-change* boundary consults the snapshot-frozen carry-vs-reset flags (default reset unless marked carry ⇒ `bandOffsetQ = 0`, §17.2); commitment-pool carry-vs-reset stays plan-change-scoped (slice 05). +- **Change mode**: `(changeEffectiveAt, changeMode)` is consumed from Subscriptions; the mode's only evaluation-side effect is the boundary position — WHEN and the up/down asymmetry policy are Subscriptions' (§17.2). Rating MUST NOT decide the change mode. +- **Seat-count changes are not a Rating split point**: a mid-period `subscription_seat_count` change for a `per_unit` row reaches Rating only as a Subscriptions-driven change boundary (Subscriptions owns runtime proration execution and WHEN); each slice rates with the seat count frozen in its context. The concrete transport (a `changeEffectiveAt`-shaped boundary vs Subscriptions-side proration) is an **open with Subscriptions** — recorded, default: the change-boundary reading. +- **Anchor change across a plan change**: the enclosing `AnchorPeriod` geometry is fixed once, at `periodStart`, by the anchor policy then in force (planA's); a plan change that alters `billingAnchorPolicy` takes effect from the **next** period boundary — asserted in the joint anchor fixture; period identity itself stays Billing/Subscriptions'. +- **Cross-boundary guarantee**: pricing rejects in-place proration for a mid-cycle change crossing currency, region, or billing frequency (handled as cancel + new) — Rating never receives a cross-boundary in-place split ([`../../../pricing/docs/design/06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md)). +- **Determinism**: split geometry, fractions, and obligations are pure functions of the frozen tuple (01 §4.2); replay yields byte-identical slices; corrections to already-rated portions are deltas via the Adjustment path — never a mutation. + +## 5. Traceability + +- **PRD**: §6.11 (all three FRs), §17.1 period-level phase note, §17.2 (floor/cap + plan-change boundary contracts), §9.2 (Billing `periodState`/obligation, Subscriptions input, pricing read-model contracts), AC 7/17, §7.1 NFRs, §15 (floor-vs-coupon open), §17.4 (floor/cap execution phasing). +- **Seams**: P1 (`prorationBasis` incl. `none`, CI gate), P2 (`billingAnchorPolicy` + D-20 clamp) — [`../SEAMS.md`](../SEAMS.md), incl. the ownership matrix row "Proration/plan-change *math* — Rating evaluates; Subscriptions executes". +- **Decisions**: T-D-07 (enums verbatim + CI gate), T-D-03 (snapshot composition carries the frozen policy fields), T-D-04 (snapshot-only replay for corrections) — [`../DECISIONS.md`](../DECISIONS.md). +- **Pricing design set**: [`../../../pricing/docs/design/06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md) (K1/K2 enum ownership, D-20, cross-boundary rejection, joint fixtures). +- **Slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, guards, envelope, determinism keys), [`03-metering-models.md`](./03-metering-models.md)/[`05-commitments-reservations.md`](./05-commitments-reservations.md) (carry-vs-reset consumers), [`07-currency-fx.md`](./07-currency-fx.md) (floor/cap currency comparison), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (delta/correction boundary), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (boundary contracts). diff --git a/gears/bss/rating/docs/design/10-governance-asc606.md b/gears/bss/rating/docs/design/10-governance-asc606.md new file mode 100644 index 000000000..a5356bcc1 --- /dev/null +++ b/gears/bss/rating/docs/design/10-governance-asc606.md @@ -0,0 +1,337 @@ + + + +# DESIGN — Governance & ASC 606 (Slice 10) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-governance-asc606` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Single Governance Engine (normative)](#41-single-governance-engine-normative) + - [4.2 The Four Publish-Time Validators (normative)](#42-the-four-publish-time-validators-normative) + - [4.3 Ledger Separation Rationale (normative)](#43-ledger-separation-rationale-normative) + - [4.4 ASC 606 Reference Emission (normative)](#44-asc-606-reference-emission-normative) + - [4.5 Bundle Rev-Share Pass-Through (normative)](#45-bundle-rev-share-pass-through-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This is the one Rating slice that is **publish-time, not hot-path**. Its governance concern +runs inside the *pricing gear's* publish pipeline, not in rating-core: per the resolved seam G1 +([`../SEAMS.md`](../SEAMS.md), T-D-06) there is exactly **one** catalog-publish approval engine +— pricing Slice 5's `MaterialityEvaluator` + `ApprovalWorkflow` with its own `approval_policy` +resource and FinanceReviewer approver +([`../../../pricing/docs/design/05-governance.md`](../../../pricing/docs/design/05-governance.md)). +Rating contributes its **four publish-time checks as registered fail-closed validators** in +that pipeline (§4.2) and runs **no second workflow**: no Rating approval state machine, no +approver role, no approval store. The ledger's `dual_control_policy` stays a separate bounded +context — same maker-checker *pattern*, different *policy* (§4.3). + +The slice's two evaluation-side concerns are deliberately thin **pass-throughs**: the ASC 606 +references `performanceObligationRef` + `sspSnapshotPointer` ride every outcome envelope +(nullable, both null at MVP, immutable once non-null — §4.4), and bundle `sum_of_parts` +evaluation **sums** component lines while reading rev-share only as the publish-normalized +`effective_share_bp` (seam B1, §4.5). Rating never recomputes supplied evidence +([`../PRD.md`](../PRD.md) §14) — it validates at publish, then evaluates over what was +validated. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-publish-approval-governance` | The four checks — ambiguous precedence, ambiguous meter mapping, missing anti-drift cap on material chains, undeclared-dimension contract overlays — register as fail-closed validators in the pricing Slice 5 pipeline (§4.1, §4.2); every run emits auditable events with actor, before/after references, and effective times via the pricing audit trail. | +| `cpt-cf-bss-rating-fr-asc606-traceable-identifiers` | The outcome envelope always carries `performanceObligationRef` + `sspSnapshotPointer` (nullable; both null at MVP); non-null values are immutable once emitted — later catalog changes never alter an emitted reference (§4.4). | +| `cpt-cf-bss-rating-contract-pricing-readmodel` (§9.2 — seam B1 home) | `sum_of_parts` summing at eval is Rating's; rev-share is read exclusively as `effective_share_bp` (normalized at pricing publish, D-07) and passed through untouched (§4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-audit-segregation` | Pricing Slice 5 engine + registered validators | 100% of material publishes carry the single engine's multi-approver sign-off; the Rating validators run fail-closed on every such publish; audit rows commit hash-chained in the same transaction (D-14) — Rating adds no second audit store | Publish-pipeline audit-completeness test | +| `cpt-cf-bss-rating-nfr-resilience` (fail-closed posture) | Validator verdicts | A validator that cannot evaluate its subject fails closed — it blocks the publish rather than guessing; there is no advisory/warn-only mode for the four checks | Publish-pipeline negative fixtures | +| Hot-path NFRs (`throughput-latency`, `horizontal-scale`) | Not allocated here | Publish-time slice: zero hot-path cost; the ASC refs and effective shares are snapshot pass-throughs stamped during ordinary emission | Design | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | The adopt-don't-fork precedent: governance takes the same posture — one engine (pricing Slice 5), Rating registers rules into it rather than forking a workflow (T-D-06). A dedicated governance ADR is planned per the [`../DESIGN.md`](../DESIGN.md) ADR index; until seeded, T-D-06 is the decision of record. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-gov` + +```text +Pricing publish pipeline MaterialityEvaluator · ApprovalWorkflow · approval_policy +(pricing gear, publish-time) (FinanceReviewer) — hosts the registered validators + ▲ + │ registers (fail-closed) +Rating validator set precedence-ambiguity · meter-mapping-injectivity · +(this slice, semantics SoR) anti-drift-cap-presence · undeclared-dimension-overlay + — +rating-core emission (hot path) Asc606RefResolver (null at MVP) · RevSharePassThrough +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The four validator rules (executed by the pricing publish pipeline); envelope contributors at step 9 | Validator rules hosted in the pricing gear's publish pipeline; Rust modules in rating-core for emission | +| Domain | Validator rule specs + failure semantics; ASC ref sourcing/immutability; effective-share pass-through rule | Rust; GTS + Rust domain structs | +| Infrastructure | **None owned** — approval state, `approval_policy`, and the hash-chained `pricing_audit_log` are the pricing gear's (D-14) | Pricing gear storage; Rating persistence for envelopes | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### One engine, registered rules + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-one-engine-gov` + +Catalog-publish governance is the pricing gear's; Rating contributes rule content, never +workflow machinery. Materiality thresholds, the two-person invariant, TOCTOU content pinning, +and approver scope are pricing Slice 5 semantics that this slice neither extends nor overrides +(seam G1; T-D-06). + +#### Enforce at publish, trust at evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-publish-enforce-gov` + +The four checks make bad configurations unpublishable, so the hot path can rely on them as +catalog guarantees (the 01 §4.1/M11 posture); evaluation still fails closed if an invariant is +somehow violated at runtime, but never re-validates per event. + +#### Pass through evidence, never re-derive + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-pass-through-gov` + +ASC 606 references, `glCode`, and `effective_share_bp` are frozen inputs consumed as-is +([`../PRD.md`](../PRD.md) §14): Rating stamps them onto the envelope and never recomputes, +re-normalizes, or backfills supplied evidence. + +### 2.2 Constraints + +#### No second workflow, no approval state + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-second-workflow-gov` + +Rating holds no approval state machine, no approver role, no approval store, and exposes no +approval API ([`../PRD.md`](../PRD.md) §6.12). Contract-level overrides (step 5) are governed +by Contracts, not by this slice (seam G1). + +#### Publish-time only + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-publish-time-gov` + +The validators execute only inside the pricing gear's publish pipeline; nothing from this slice +runs per evaluation except stamping the pass-through envelope fields. There is no per-event +governance check on the hot path. + +#### Emitted references are immutable + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-immutable-refs-gov` + +A non-null `performanceObligationRef` / `sspSnapshotPointer`, once emitted, never changes; +subsequent catalog changes MUST NOT alter an emitted reference ([`../PRD.md`](../PRD.md) +§6.12, AC 11). Downstream MAY ignore null fields. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-gov` + +- **`ValidatorSpec`** — the identity, subject, check predicate, and failure code of one of the four publish-time checks (§4.2); Rating is the SoR for the rule *semantics*, the pricing pipeline is the execution host. +- **`ValidatorVerdict`** — `pass` or `fail(code, enumerated findings)`; fail blocks the publish; verdicts are audit-payload material (actor, before/after references, effective times), never stored by Rating. +- **`Asc606Refs`** — `performanceObligationRef` + `sspSnapshotPointer` (nullable, both null at MVP) + the `glCode` pass-through; sourced from frozen Catalog/Contracts inputs, stamped at emission (§4.4). +- **`EffectiveRevShare`** — per `(bundle, vendor SKU)`: `effective_share_bp` + `platform_cut_bp`, read from the published read model; guaranteed by pricing publish to sum to exactly 10000 bp (D-07); read-only in Rating (§4.5). +- **Not owned**: approval units, materiality policy, `approval_policy`, audit rows — pricing Slice 5 entities. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-governance-gov` + +- **`PublishValidatorSet`** — the four `ValidatorSpec`s registered into the pricing aggregate fail-closed validation pipeline (execution and failure semantics per §4.1/§4.2). +- **`Asc606RefResolver`** — resolves the ASC refs from the frozen snapshot inputs (null at MVP), stamps them onto the outcome envelope at step 9, and enforces emit-once immutability for non-null values. +- **`RevSharePassThrough`** — attaches the published `effective_share_bp` set to bundle component lineage untouched; the bundle-level summing itself is the ordinary pipeline totaling of component lines (§4.5). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-validator-registration-gov` + +The **validator registration contract** (Rating → pricing publish pipeline): each registered +rule declares its id, subject (the submitted publish unit), verdict shape +(`pass | fail(code, findings)`), and audit payload. Execution points and failure semantics are +the pipeline's, restated normatively in §4.2 +([`../../../pricing/docs/design/01-foundation.md`](../../../pricing/docs/design/01-foundation.md)). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-asc606-envelope-gov` + +The **ASC 606 envelope fields** on every resolved outcome (01 §4.4): `performanceObligationRef`, +`sspSnapshotPointer` (both nullable, null at MVP), `glCode`, and — on bundle component lines — +the effective-share lineage. Consumers (Billing/Finance) MAY ignore nulls; recognition is +theirs, not Rating's. + +External boundary contracts (the pricing read-model input contract carrying the registration +and pass-through clauses) are owned by [`11-consumer-contracts.md`](./11-consumer-contracts.md). + +### 3.4 Internal Dependencies + +[`01-foundation.md`](./01-foundation.md) owns the emission envelope the ASC refs and lineage +ride on, and the catalog-guarantee posture the validators underwrite. +[`03-metering-models.md`](./03-metering-models.md) defines the injective `(meter, dimensionKey)` +mapping that validator 2 enforces at publish; +[`04-overlays-precedence.md`](./04-overlays-precedence.md) defines the precedence and +anti-drift-cap semantics behind validators 1 and 3. Bundle component lines are ordinary +per-line runs (slices 02–07); [`11-consumer-contracts.md`](./11-consumer-contracts.md) +formalizes the cross-gear surface. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing Slice 5 (governance) | The single approval engine: `MaterialityEvaluator`, `ApprovalWorkflow`, `approval_policy` (FinanceReviewer); the hash-chained audit trail (D-14) | [`../../../pricing/docs/design/05-governance.md`](../../../pricing/docs/design/05-governance.md); SEAMS G1 | +| Pricing Foundation (publish path) | The aggregate fail-closed validation pipeline — the registration surface; submit pre-check + commit re-run | [`../../../pricing/docs/design/01-foundation.md`](../../../pricing/docs/design/01-foundation.md) | +| Pricing Slice 8 (bundles) | `sum_of_parts` component `planId` sets; `effective_share_bp` + `platform_cut_bp` normalized at publish onto the residual absorber (default platform, D-07) | [`../../../pricing/docs/design/08-bundles.md`](../../../pricing/docs/design/08-bundles.md); SEAMS B1 | +| Contracts | Contract-overlay governance (step 5 overrides) stays there; validator 4 checks the published Plan/SKU dimension set | SEAMS G1; [`../PRD.md`](../PRD.md) §17.1 step 5 | +| Finance / Billing | Consume ASC refs and `glCode`; own recognition schedules and journal entries | [`../PRD.md`](../PRD.md) §5.2, §7.2 | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-publish-gate-gov` + +**Publish gate** (runs in the pricing gear): + +1. An author submits a catalog publish (any pricing authoring surface). +2. The aggregate fail-closed validation pipeline runs, including the four registered Rating validators; any failure blocks with an enumerated report — no `PlanPublished`, no read-model warm. +3. `MaterialityEvaluator` classifies the change; a material change opens an approval unit; an independent FinanceReviewer approves or rejects (two-person, content-pinned). +4. The publish commit re-runs the pipeline — the Rating validators included — inside the commit transaction; a commit-time failure voids the approval and returns the subject to draft. +5. Audit rows (actor, before/after references, effective times, validator verdicts) commit hash-chained with the mutation into `pricing_audit_log` (D-14); rating-core stores nothing. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-bundle-sum-passthrough-gov` + +**Bundle summing and pass-through** (runs in rating-core): + +1. A `sum_of_parts` bundle line arrives; the pinned snapshot carries the component `planId` set and the publish-normalized `effective_share_bp` + `platform_cut_bp`. +2. Each referenced component resolves through the ordinary per-line pipeline (steps 1–9) on its own full key; a component that fails to resolve fails the whole bundle line closed (§4.5). +3. The bundle-level amount is the sum of the component resolved amounts — the eval-time summing is Rating's; the catalog persists only the reference set. +4. Effective shares attach to the component lineage untouched — no re-derivation, no re-normalization; downstream settlement reads only effective shares (D-07). +5. `Asc606RefResolver` stamps the envelope refs (null at MVP) per §4.4. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-gov` + +**None owned.** Approval state, the `approval_policy` resource, and the hash-chained +`pricing_audit_log` are the pricing gear's (D-14; the SEAMS ownership matrix row "Audit trail / +retention — Pricing"); ledger dual-control state is the Ledger's; ASC refs and effective shares +are envelope values persisted by Rating with the outcome. Rating keeps no validator-verdict, +approval, or audit store — consistent with 01 §3.7. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-gov` + +Two placements, neither a new deployable: the **four validators execute inside the pricing +gear's publish pipeline** (publish-time; zero rating-core hot-path cost), while `Asc606RefResolver` +and `RevSharePassThrough` are rating-core code in the `rating` gear (01 §3.8). The +packaging of the registered rules (a shared library crate the pricing pipeline links vs a +pricing-side implementation of the jointly-owned rule specs) is an open engineering item to +settle with the pricing team before Design lock; either way the rule semantics SoR is this +slice and the failure semantics are the pipeline's. + +## 4. Additional Context + +### 4.1 Single Governance Engine (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-single-engine-gov` + +- There is **one** catalog-publish approval engine: pricing Slice 5 — `MaterialityEvaluator` + `ApprovalWorkflow` + its own `approval_policy` resource, approver role FinanceReviewer (seam G1, **RESOLVED → single engine**; T-D-06). Rating runs no second workflow and defines no approval semantics — the engine's materiality, two-person, and content-pinning rules are adopted as-is (§2.1). +- Rating's four publish-time checks (§4.2) are **registered fail-closed validators** in the pricing aggregate validation pipeline; they run on every material publish at submit pre-check and again inside the publish-commit transaction. +- **Contract-level overrides** (step 5) are governed by Contracts, not by the pricing engine and not by Rating (seam G1). +- The `cpt-cf-bss-rating-nfr-audit-segregation` threshold (100% of material publishes multi-approved, validators fail-closed, complete before/after audit) is satisfied *inside* the pricing pipeline: validator verdicts and approvals commit as hash-chained audit rows with actor, before/after references, and effective times (D-14). + +### 4.2 The Four Publish-Time Validators (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-four-validators-gov` + +Identities per [`../PRD.md`](../PRD.md) §6.12; check content per the underlying §17.1 rules: + +| # | Validator | Checks (fail-closed at publish) | PRD anchor | +|---|-----------|--------------------------------|------------| +| 1 | Ambiguous precedence | Equal `precedence` among `PriceOverlay`s with overlapping scope within one class is rejected; the runtime class-order + `priceOverlayId` tie-break stays a safety net, never a license to publish ambiguity | §6.12; §17.1 step 4 | +| 2 | Ambiguous meter mapping | The `(meter, dimensionKey)` → charge-line mapping MUST be injective per plan revision; a non-injective mapping is a configuration error | §6.12; §17.1 step 3 | +| 3 | Missing anti-drift cap on material chains | A material multi-link overlay chain (partner → reseller → customer) without a configured `maxCumulativeMarkup` fails publish; single-link/non-material overlays MAY warn | §6.12; §17.1 step 4; §15 | +| 4 | Undeclared-dimension overlay | A contract overlay MUST NOT introduce metering dimensions absent from the published Plan/SKU revision | §6.12; §17.1 step 5 | + +- **Failure semantics**: any failing validator blocks the publish (no `PlanPublished`, no read-model warm); a commit-time failure voids the pending approval; a validator that cannot evaluate its subject fails closed. Findings are enumerated so authoring can remediate in one pass. +- **Open** ([`../PRD.md`](../PRD.md) §15): the default `maxCumulativeMarkup` value and the clamp-vs-hard-fail mode for validator 3's runtime counterpart — the publish-time cap-presence rule above is normative regardless. +- **Open (enforcement point, validator 4)**: [`../PRD.md`](../PRD.md) §6.12 registers the check in the pricing pipeline, while [`../SEAMS.md`](../SEAMS.md) G1 keeps contract-override governance with Contracts; whether the same registered rule additionally gates the Contracts-side override publish (vs gating only the catalog side it can see) needs a joint clarification before Design lock. Recorded, not resolved here. + +### 4.3 Ledger Separation Rationale (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-ledger-separation-gov` + +Reproduced from the [`../SEAMS.md`](../SEAMS.md) governance topology note — three maker-checker +engines share the *pattern*, not the *policy*: + +- **Ledger** — `dual_control_policy` + Finance Approver; subject = financial **postings** (journal entries, refunds over threshold, backdating, period reopen, suspense clearing, GL write-off); materiality = amount/entity thresholds + backdating days. +- **Pricing Slice 5** — `MaterialityEvaluator` + `ApprovalWorkflow` + a **separate** `approval_policy` resource; FinanceReviewer; subject = catalog **publish** (price/plan deltas + always-material triggers). Pricing itself cites the ledger `dual_control_policy` as precedent and deliberately keeps its own policy resource. +- **Rating** — folds into pricing Slice 5: same subject (catalog publish), so registering validators there is consolidation, not duplication. + +Merging catalog-publish governance into the ledger would be a **category error**: different +subject (commercial configuration vs financial posting), different approver role, different +materiality; a price change becomes a posting only later, via Rating → Billing → Ledger. No +shared platform governance library exists today (`gears/bss/libs/` holds only `coord`). +**Optional post-launch**: extract a shared maker-checker/audit *mechanism* into `libs/` that +pricing, tariffs, and ledger instantiate each with its own policy — DRY the mechanism, not the +policy. Explicitly not a launch blocker. + +### 4.4 ASC 606 Reference Emission (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-asc606-refs-gov` + +- Every resolved outcome carries `performanceObligationRef` and `sspSnapshotPointer` on the envelope, **nullable when not applicable** ([`../PRD.md`](../PRD.md) §6.12, AC 11; 01 §4.4). +- **Both resolve null at MVP**: pricing supplies `glCode` now and defers SSP catalog support to Future — no contradiction; the fields exist so the envelope shape never changes when sourcing lands (seam ASC). `glCode` flows now as an ordinary frozen pass-through. +- **Immutability**: a non-null reference, once emitted, is immutable; subsequent catalog changes MUST NOT alter an emitted reference. The refs are frozen-input pass-throughs — Catalog/Contracts supply them, Rating consumes and never recomputes ([`../PRD.md`](../PRD.md) §14). +- **Boundary**: Billing/Finance MAY ignore null fields; recognition schedules, revenue allocation, and journal entries are Finance/Billing — explicitly out of scope here ([`../PRD.md`](../PRD.md) §5.2, §7.2). + +### 4.5 Bundle Rev-Share Pass-Through (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-revshare-passthrough-gov` + +- For a `sum_of_parts` bundle, the **eval-time summing is Rating's**: each referenced component `planId` resolves through the ordinary per-line pipeline, and the bundle-level amount is the sum of component resolved amounts; the catalog persists only the reference set (seam B1; [`../../../pricing/docs/design/08-bundles.md`](../../../pricing/docs/design/08-bundles.md)). +- **Partial failure fails the bundle**: if any referenced component fails to resolve (no eligible window on its full key at `t`, unsellable market), the whole `sum_of_parts` bundle line **fails closed** — a partial sum is a misprice, never emitted. Component currency/frequency coverage is a pricing publish-time guarantee (pricing design 08) relied on here; a runtime miss is a defensive fail-closed. +- **Open — bundle-level coupon attachment**: whether a coupon can target the bundle total (vs the component lines it decomposes into) is unpinned; until settled with Promotions/pricing, coupons attach to component lines per their ordinary `applyScope` (slice 06) and no bundle-total scope exists. +- **Rev-share arrives normalized**: pricing publish normalizes the residual onto the bundle's absorber (default **platform**) so `SUM(effective_share_bp) + platform_cut_bp = 10000` exactly (D-07); typed authored values stay pricing-side audit material. +- **Pass-through, never re-derivation**: Rating and downstream read **only** effective shares; Rating attaches them to component lineage untouched and never re-normalizes. Monetary cent-level rounding at settlement is a downstream rule (it also lands on the absorber) and is not evaluation math. +- Component lines remain individually addressable in the lineage so settlement can attribute the summed amount per vendor SKU without recomputation; `invoiceItemization` (`aggregate | itemize`) is a published presentation field consumed downstream, not evaluation math. + +## 5. Traceability + +- **PRD**: §6.12 (both FRs), §7.1 `nfr-audit-segregation`, §9.2 pricing read-model contract (validator registration + B1 pass-through clauses), §5.1 (bundle summing in-scope row), §5.2/§7.2 (recognition exclusions), §14 (evidence consumed, never recomputed), §15 (anti-drift cap open), AC 11, NFR AC 2, §17.1 steps 3–5 (the rules the validators enforce). +- **Seams**: G1 (RESOLVED → single engine; governance topology note), B1 (bundle summing + effective-share pass-through), ASC (null at MVP) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-06 (single engine; ledger separate), T-D-08 (effective rev-share pass-through leg) — [`../DECISIONS.md`](../DECISIONS.md). +- **Pricing design set**: [`../../../pricing/docs/design/05-governance.md`](../../../pricing/docs/design/05-governance.md) (the engine), [`../../../pricing/docs/design/01-foundation.md`](../../../pricing/docs/design/01-foundation.md) (the validation pipeline), [`../../../pricing/docs/design/08-bundles.md`](../../../pricing/docs/design/08-bundles.md) (D-07 normalization). +- **Slices**: [`01-foundation.md`](./01-foundation.md) (emission envelope; catalog-guarantee posture), [`03-metering-models.md`](./03-metering-models.md)/[`04-overlays-precedence.md`](./04-overlays-precedence.md) (the evaluation semantics the validators protect), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (cross-gear contract surface). diff --git a/gears/bss/rating/docs/design/11-consumer-contracts.md b/gears/bss/rating/docs/design/11-consumer-contracts.md new file mode 100644 index 000000000..5aa7afe4c --- /dev/null +++ b/gears/bss/rating/docs/design/11-consumer-contracts.md @@ -0,0 +1,440 @@ + + + +# DESIGN — Consumer & Integration Contracts (Slice 11) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-design-consumer-contracts` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Rating Handoff Contract (normative)](#41-rating-handoff-contract-normative) + - [4.2 Pricing Read-Model Input Contract (normative)](#42-pricing-read-model-input-contract-normative) + - [4.3 Subscriptions Input Contract (normative)](#43-subscriptions-input-contract-normative) + - [4.4 Finance FX Input Contract (normative)](#44-finance-fx-input-contract-normative) + - [4.5 Promotions Coupon Snapshot Contract (normative)](#45-promotions-coupon-snapshot-contract-normative) + - [4.6 Billing periodState and Obligation Contract (normative)](#46-billing-periodstate-and-obligation-contract-normative) + - [4.7 pricingSnapshotRef Segment Map (normative)](#47-pricingsnapshotref-segment-map-normative) + - [4.8 Canonical Naming (normative)](#48-canonical-naming-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **integration surface** of the Rating gear: every fact that crosses the gear boundary, in +either direction, is contracted here and nowhere else — the Foundation +([`01-foundation.md`](./01-foundation.md) §3.3) delegates the external contracts to this slice, +and the step slices (02–07) consume the inputs these contracts freeze without re-negotiating +them. Six contracts partition the boundary: one **downstream handoff** — Rating, the in-process +consumer of the `evaluate` outcome envelope (§4.1) — and five **upstream inputs** — the pinned +pricing read model (§4.2), Subscriptions context (§4.3), Finance FX (§4.4), Promotions coupon +snapshots (§4.5), Billing `periodState` (§4.6) ([`../PRD.md`](../PRD.md) §9). + +Two rules shape the surface. **Adopt verbatim**: whatever the pricing gear owns — the 8-axis key, +the `modelKind` set, `prorationBasis`, `billingAnchorPolicy`, band shapes — arrives as published, +CI-gated against drift; no local re-declaration exists to diverge (SEAMS C1/P1). **One writer per +fact**: every boundary fact has exactly one producing gear, and Rating writes back to none — it +reads frozen inputs, records lineage, and seals exactly its own `pricingSnapshotRef` segments +(SEAMS S1). A missing required input has no default: evaluation fails closed at the boundary +([`../PRD.md`](../PRD.md) §7.1, §9.2). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-interface-tariff-evaluation` | The §9.1 conceptual contract realized as the in-process Rating handoff (§4.1): frozen context in → resolved outcome + sealed `pricingSnapshotRef` + evaluation metadata (applied coupons, `tierAggregationWindow`, `fxTableVersion`, granularity) out; replay-safe per 01 §4.2; incompatible changes take a major version bump — snapshot semantics are part of the contract. | +| `cpt-cf-bss-rating-contract-rating-handoff` | §4.1: the full outcome envelope (rates, model kind, tier thresholds, overlay stack lineage, coupon pre/post amounts, FX record, obligations, ASC 606 refs, rounding-policy id) + the stable `{skuId, planId, priceId}` triple on every emission; Rating maps to RatedCharge/BillableItem and owns persistence, dedup, and the windowed `Q`. | +| `cpt-cf-bss-rating-contract-pricing-readmodel` | §4.2: pin-one-`CatalogVersion` read discipline; all four `PriceWindow*` events + `CatalogVersionPublished` (W1); enums adopted verbatim under the `pricing.contracts.enum_drift` CI gate (P1/P2); the snapshot-replay guarantee (W2); bundle `sum_of_parts` summing with `effective_share_bp` pass-through (B1). | +| `cpt-cf-bss-rating-contract-subscriptions-input` | §4.3: `phase_id` at `t`, eligibility inputs (`activatedAt`, bound cohort via the pinned price id), the seat count behind `per_unit`, `(changeEffectiveAt, changeMode)` consumed — never decided, and the frozen `(currency, region)` binding segment (S1). | +| `cpt-cf-bss-rating-contract-finance-fx-input` | §4.4: FX tables + lock policies versioned by `fxTableVersion` — a member of the determinism tuple; per-window rate-lock and invoice-period modes; a needed conversion without a policy/table record fails closed. | +| `cpt-cf-bss-rating-contract-promotions-coupon` | §4.5: the frozen coupon-snapshot field set; Rating applies and records, never mutates redemption state; missing `applyScope` (or `stackSequence` under `ordered_stack`) fails closed. | +| `cpt-cf-bss-rating-contract-billing-periodstate` | §4.6: `periodState ∈ {open, closed_posted}` as a required input (missing fails closed); full-precision amounts + `PeriodFloorCapObligation` outbound; Billing executes floor/cap and all rounding. | +| `cpt-cf-bss-rating-fr-snapshot-carry` | §4.7: the canonical three-writer segment map reproduced identically from 01 §4.3 / SEAMS S1 — each contract writes only its own segments; Rating (composition SoR) seals the ref at emission. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | §4.2 pin discipline + cache | The read-path cost is the read-model pin: pricing publishes contract fields as columns of existing read-model rows (no extra read-path lookups); events only invalidate the non-authoritative cache | Load test; **targets provisional — NFR workshop** ([`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-rating-nfr-resilience` | All six contracts | Fail-closed absence semantics per contract — missing `periodState`, coupon policy, FX record, or a torn snapshot pre-stamp never yields a guessed price; retries replay the 01 §4.2 keys | Chaos/retry test | +| `cpt-cf-bss-rating-nfr-audit-segregation` | §4.2 publish-side duty | Rating's four publish-time checks run as registered fail-closed validators inside the pricing Slice 5 engine — one workflow, one audit trail (validators owned by slice 10) | Joint publish-path test | +| Decimal precision of emitted amounts | §4.6 | Amounts cross the Billing boundary at full intermediate precision; the concrete DECIMAL precision is fixed in this Design before lock ([`../PRD.md`](../PRD.md) §4.1) | **Open — set with Billing** | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| `cpt-cf-bss-rating-adr-scope-key-adoption` | Adopt the pricing 8-axis canonical scope key verbatim (selection + non-overlap); cohort generation selected by the pinned price id; no Rating-local key — the §4.2 read model is consumed on exactly this key (SEAMS K1–K5). | +| `cpt-cf-bss-pricing-adr-canonical-scope-key` (adopted) | The key definition itself — the manifest key extended additively; the pricing gear is its SoR. | +| `cpt-cf-bss-pricing-adr-grandfathering-cohort-axis` (adopted) | `cohort` = the cutover instant; Rating resolves the generation by the cohort of the subscription's pinned price id (§4.3 eligibility inputs). | +| `cpt-cf-bss-pricing-adr-pricewindow-consolidation` (adopted) | `PriceWindow*` events are produced by the pricing gear; Rating consumes all four (incl. `Cancelled`) as read-only resolution inputs (§4.2). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-cc` + +```text +Upstream SoRs Pricing (pinned read model · PriceWindow* ×4 · CatalogVersionPublished) · + Subscriptions · Finance (FX) · Promotions (coupons) · Billing (periodState) + ▼ frozen inputs (§4.2–§4.6) +Boundary (this slice) contract shapes · read-model pin + input-verification adapters · + enum-conformance CI gate · snapshot segment-writer rules (§4.7) + ▼ +Evaluation pipeline steps 1–9 over the frozen context (01; step slices 02–07) + ▼ outcome envelope (§4.1) +Rating (downstream) RatedCharge/BillableItem mapping · persistence · dedup · windowed Q +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Boundary adapters: read-model pin, context-input verification, outcome handoff | Rust modules in the `rating` gear (rating-core crate) | +| Domain | The six contract shapes + the `pricingSnapshotRef` segment-writer rules | Rust; GTS + Rust domain structs | +| Infrastructure | Event consumption (`PriceWindow*`, `CatalogVersionPublished`) invalidating the 01 §3.7 cache; build-time enum-conformance fixtures | In-process cache (Foundation-owned); CI gate `pricing.contracts.enum_drift` | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Adopt verbatim, gate the drift + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-adopt-verbatim-cc` + +Enums, the scope key, model kinds, and band shapes are the pricing gear's; Rating consumes them +exactly as published, and the `pricing.contracts.enum_drift` CI gate (Critical) blocks any build +whose adopted enums diverge (SEAMS P1; pricing design 06 §7). + +#### One writer per boundary fact + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-single-writer-cc` + +`Q` is Rating's, redemption state Promotions', FX tables Finance's, `periodState` Billing's, the +`(currency, region)` binding Subscriptions', the catalog pricing's. Rating reads frozen facts, +records lineage, and writes exactly its four eval-time snapshot segments (§4.7) — never a +foreign fact. + +#### Frozen in, full precision out + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-frozen-in-precision-out-cc` + +Every inbound fact is a frozen snapshot or version; every outbound amount leaves at full +intermediate precision with discount lineage and the rounding-policy id. Execution — rounding, +floor/cap, posting — is downstream's (01 §4.4). + +### 2.2 Constraints + +#### In-process handoff, event-only asynchrony + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-inprocess-boundary-cc` + +The Rating handoff is an in-process call within the one `rating` gear deployable (01 §3.8), not a +service API or queue; the only asynchronous surface consumed is the +pricing event set (four `PriceWindow*` + `CatalogVersionPublished`). + +#### No local enum or key extension + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-extension-cc` + +Any extension of an adopted enum or key axis is a **versioned contract change on the pricing +side** (pricing design 06, K1), never a Rating-local addition; unrecognized values fail the +conformance fixtures at build time, not silently at evaluation time. + +#### Rating-side schema confirmed intra-gear before Design lock + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-rating-contract-draft-cc` + +Since consolidation (ADR-0002 / T-D-16) the §4.1 handoff is **no longer a cross-gear contract** — it +is the internal core↔pipeline crate API, and the ratifying counterpart is pipeline slice +[`15-rated-output-balance-effects.md`](./15-rated-output-balance-effects.md), not an external +draft/empty Rating PRD (ADR-0002 dissolves review-finding C6). The RatedCharge/BillableItem mapping +in §4.1 is the core-side normative proposal that slice 15 ratifies (intra-gear open, tracked in +[`../DECISIONS.md`](../DECISIONS.md)); it stays intent-stable while slice 15 is authored beyond +skeleton, and incompatible changes take a major version bump (PRD §9.1). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-cc` + +The evaluation shapes (`EvaluationContext`, `EvaluationUnit`, `ResolvedPriceOutcome`, +`pricingSnapshotRef`) are owned by 01 §3.1; this slice fixes **which shapes cross the boundary +and who writes them**: + +- **Outcome envelope** (→ Rating, §4.1) — `ResolvedPriceOutcome` + obligations (`TrueUpObligation`: slice 05 shape; `PeriodFloorCapObligation`: slice 09 shape) + discount lineage + evaluation metadata + the sealed `pricingSnapshotRef` + `{skuId, planId, priceId}`. +- **Pinned catalog read model** (← pricing, §4.2) — 8-axis key rows, windows, `modelKind` rows + bands, eligibility/cohort, `prorationBasis`/`billingAnchorPolicy`, prepaid-grant set, bundle component sets + `effective_share_bp`. +- **Subscription context** (← Subscriptions, §4.3) — `phase_id`, `activatedAt` + bound cohort, seat count, `(changeEffectiveAt, changeMode)`, the frozen `(currency, region)` binding. +- **FX policy record** (← Finance, §4.4) — rate tables + lock policy + `fxTableVersion`. +- **Coupon snapshot** (← Promotions, §4.5) — the frozen field set of §4.5. +- **Period context** (← Billing, §4.6) — `periodState` for the period covering `t`. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-component-boundary-surface-cc` + +- **`ReadModelPinAdapter`** — pins exactly one committed `CatalogVersion` per resolution run (monotonic read; `CatalogVersionPublished` + warm-completion marker required; pin lag ≤ 5s; no draft read — pricing design 01 §4.4); consumes the five pricing events to invalidate the Foundation's resolved-window cache. +- **`ContextInputVerifier`** — per-contract presence/shape verification of the Subscriptions/Finance/Promotions/Billing inputs before pipeline entry; absence surfaces as the fail-closed problem values of 01 §3.3, never a default. +- **`OutcomeHandoff`** — the in-process emission surface to Rating: asserts the rating-compat triple and the sealed ref on every emission (sealing itself is 01's `SnapshotComposer`/`EmissionGuard`; this is where the envelope leaves the gear). +- **Enum-conformance fixtures** (build-time) — the CI-gate fixture set (`pricing.contracts.enum_drift`, Critical) pinning `modelKind`, `prorationBasis`, and `billingAnchorPolicy` byte-identical to the pricing canonical set (SEAMS P1; T-D-07). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-rating-handoff-cc` + +**Rating handoff** (provided; in-process): `evaluate(EvaluationContext) → ResolvedPriceOutcome` +and `reresolve(window, pinnedSnapshotRef, priorRatedVersion) → deltas` (01 §3.3) — envelope, +duties, and idempotency per §4.1. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-pricing-readmodel-cc` + +**Pricing read-model input** (required): the pricing catalog read-model interface +(`cpt-cf-bss-pricing-interface-catalog-read-model`) plus the event set — pin discipline, payload, +and guarantees per §4.2. + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-interface-context-inputs-cc` + +**Context inputs** (required): Subscriptions (§4.3), Finance FX (§4.4), Promotions coupons +(§4.5), Billing `periodState` (§4.6) — all frozen at entry, all fail-closed on absence; the PRD +§9.2 contract ids map one-to-one onto the §4 subsections (§1.2 Functional Drivers). + +### 3.4 Internal Dependencies + +Everything — this slice formalizes the boundary the whole set evaluates behind: +[`01-foundation.md`](./01-foundation.md) owns `evaluate`/`reresolve`, composition, and the +emission guards; [`05`](./05-commitments-reservations.md)/[`09`](./09-period-plan-change.md) own +the obligation shapes the §4.1 envelope carries; [`06`](./06-coupons.md)/[`07`](./07-currency-fx.md) +evaluate the §4.5/§4.4 policy semantics; [`08`](./08-retroactivity-corrections.md) exercises the +§4.2 snapshot-replay guarantee; [`10`](./10-governance-asc606.md) owns the registered validators +and the rev-share pass-through recorded in §4.2. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|--------------------------|----------| +| Pricing (Product Catalog) | pinned read model; `PriceWindow*` ×4 + `CatalogVersionPublished`; bundle component sets + `effective_share_bp` | §4.2; SEAMS C1, W1, W2, P1/P2, B1, M11 | +| Rating | outcome envelope out; windowed `Q`, usage dedup, rated-output persistence stay Rating-side | §4.1; SEAMS M7 | +| Subscriptions | `phase_id`, eligibility inputs, seat count, `(changeEffectiveAt, changeMode)`, `(currency, region)` binding | §4.3; SEAMS S1 | +| Finance | FX tables + lock policies, `fxTableVersion` | §4.4 | +| Promotions | frozen coupon snapshots | §4.5 | +| Billing | `periodState` in; obligations + full-precision amounts out | §4.6 | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-flow-evaluate-handoff-cc` + +**Evaluate and hand off one line** (boundary view of `cpt-cf-bss-rating-seq-evaluate-tariff`): + +1. Rating assembles the `EvaluationContext` from the five upstream inputs (§4.2–§4.6) plus the evaluation unit it owns (normalized `UsageRecord` or windowed `Q`). +2. `ReadModelPinAdapter` pins the committed `CatalogVersion`; `ContextInputVerifier` fails closed on any absent required input. +3. The Foundation pipeline evaluates steps 1–9 (01 §3.6). +4. `OutcomeHandoff` emits the §4.1 envelope in-process; Rating maps it to RatedCharge/BillableItem, persists, and dedups on the usage idempotency key (01 §4.2). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-pricewindow-events-cc` + +**Consume the pricing event set** (SEAMS W1): + +1. Pricing emits `PriceWindowScheduled/Activated/Expired/Cancelled` and `CatalogVersionPublished` from its outbox; the activation-job events are at-least-once, idempotency-keyed, ordered per `(tenant, plan)` (pricing design 07 §7). +2. Each event invalidates the matching pages of the non-authoritative resolved-window cache (01 §3.7) — **including `Cancelled`**, so a pre-cached scheduled window that pricing later voids is retracted (the W1 failure this contract closes). +3. A `CatalogVersion` becomes pin-eligible only after `CatalogVersionPublished` **and** the warm-completion marker (§4.2); events carry no rate authority — resolution always reads the pinned model. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-storage-none-cc` + +**None owned.** This slice adds no store to the Foundation doctrine (01 §3.7); it contributes +only the invalidation triggers for the non-authoritative cache (`PriceWindow*` ×4, +`CatalogVersionPublished`). Authoritative state per boundary fact: catalog → pricing gear; +`Q`/dedup/rated output → Rating; phase structure and change policy → Subscriptions; FX → +Finance; coupon entities and redemption → Promotions; period and rounding → Billing. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-cc` + +**None beyond the Foundation's.** rating-core rides the one `rating` gear deployable (01 §3.8), so the §4.1 +handoff crosses no process boundary; the §4.2 events are the only cross-deployable transport this +slice consumes; the enum-conformance fixtures run as a build-time block (pricing design 06 §7). + +## 4. Additional Context + +### 4.1 Rating Handoff Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-rating-handoff-cc` + +**Direction**: provided to Rating, in-process (01 §3.8). **Contract**: +`evaluate(EvaluationContext) → ResolvedPriceOutcome` + `reresolve → deltas` (01 §3.3) — +replay-safe: same context + same frozen inputs ⇒ byte-identical outcome. Every emission carries +([`../PRD.md`](../PRD.md) §9.1–§9.2; 01 §3.1, §4.4): + +- resolved effective rates, model kind, tier thresholds, overlay winners + stack lineage; +- applied coupon id(s) + pre-/post-discount amounts, and the FX policy record (`fxTableVersion` / locked-rate id); +- the obligations envelope — `TrueUpObligation` (shape: slice 05) and `PeriodFloorCapObligation` (shape: slice 09) — surfaced for Billing execution, never posted by Rating; +- discount lineage (pre/post-overlay, pre/post-coupon + applied ids) for Billing/Tax gross-vs-net and audit; ASC 606 refs `performanceObligationRef`/`sspSnapshotPointer` — **null at MVP** (SEAMS ASC), with `glCode` passing through as supplied frozen evidence (PRD §14); +- evaluation metadata (applied coupons, `tierAggregationWindow`, `fxTableVersion`, granularity — PRD §9.1), the **sealed** composed `pricingSnapshotRef` (§4.7), the rounding-policy id (01 §4.4), and the stable rating-compat triple `{skuId, planId, priceId}` — ids never re-used across revisions (pricing design 06, rating-compatibility bundle). + +**Outcome → Rating mapping** (normative Rating-side proposal — ratified by the Rating design +before Design lock, §2.2): + +| Outcome element | Rating-side mapping | +|---|---| +| resolved line (usage / recurring / `capacityCharge`) | one `RatedCharge` per charge line and unit, carrying the full-precision amount + rounding-policy id; `BillableItem.pricingSnapshotRef` = the sealed ref; `{skuId, planId, priceId}` verbatim | +| zero-due `prepaid_drawdown` in-commit line (slice 05 §4.1, T-D-14) | a `RatedCharge` with amount-due 0 + notional value in lineage — itemized, never dropped | +| obligations (`TrueUpObligation`, `PeriodFloorCapObligation`) | envelope ride-along to Billing execution — never a `RatedCharge` | +| provisional invoice-period-FX amount (slice 07) | `RatedCharge` flagged provisional; the close-time delta supersedes by correction key, never mutates | +| correction deltas (slice 08) | `Adjustment` entities keyed by the correction key `(window[, slice], prior-rated-version, snapshot)`; the original `RatedCharge` is immutable | +| idempotency | usage key ⇒ `RatedCharge` dedup; correction key ⇒ `Adjustment` dedup — both enforced by Rating (T-D-11) | +| `CommitmentBalanceEffect` (slice 05 §4.1, T-D-10) | published by Rating to Contracts per rated outcome, idempotent on the outcome's key | + +**Duties** (PRD §9.2; SEAMS M7): Rating owns the Usage → RatedCharge pipeline, **rated-output +persistence**, **usage dedup** (authoritative), and the windowed `Q` per +`(subscription, meter, dimensionKey, window)` — single writer per key, including the per-slice +attribution and `bandOffsetQ` of a split window (slice 03 §4.3, T-D-12); Rating never +aggregates. Rating additionally owns the **period tick** that synthesizes the period-driven +evaluation units — recurring lines, capacity-flavor charges, true-up surfacing — at +`AnchorPeriod` boundaries (01 §4.2, T-D-15), and **publishes `CommitmentBalanceEffect`s to +Contracts** (T-D-10). `reresolve` deltas carry the correction key +`(window[, slice], prior-rated-version, snapshot)`; **delta dedup is Rating's (T-D-11)** — +01 §2.2. The mapping table above is the core-side proposal ratified intra-gear by pipeline slice 15 (§2.2 +constraint — confirmation open, no longer a cross-gear contract since ADR-0002); incompatible +changes take a major version bump (PRD §9.1). + +### 4.2 Pricing Read-Model Input Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-pricing-readmodel-cc` + +**Direction**: required from Pricing (Product Catalog) — the Rating side of the frozen pricing +consumer contract +([`06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md); SEAMS C1). + +- **Read model (pinned)**: rows on the 8-axis canonical scope key with UTC half-open windows (non-overlap on the full key); `modelKind ∈ {flat, per_unit, graduated, volume, package}` with the pricing §17.2 kind → formula mapping as shared SoR (T-D-05); tier bands; `priceEligibility` + `cohort` generation; `prorationBasis ∈ {calendar_days_actual, calendar_days_30, by_second, whole_unit, none}`; `billingAnchorPolicy ∈ {calendar_month, subscription_start, fixed_day(d)}` with the D-20 last-of-month clamp (anchor day preserved, no drift); the prepaid-grant set (plan-attached credit grants whose balance/drawdown executor is Billing/Rating — distinct from `commitmentPools[]`, SEAMS M8); the rating-compat triple. +- **Pin discipline** (pricing design 01 §4.4): one committed `CatalogVersion` per resolution run; a version resolves only after `CatalogVersionPublished` **and** its warm-completion marker; pin lag ≤ 5s; no draft read; no default substitution. +- **Enums verbatim (P1/P2, T-D-07)**: adopted byte-identical under CI gate `pricing.contracts.enum_drift` (**Critical**) — drift is a build-time block; the runtime alarm covers registry divergence (pricing design 06 §7). Rating never prorates a `none` row (pricing rejects `creditOnDowngrade = true` + `none` at publish) but MUST recognize the value for the conformance fixture (SEAMS P1). The plan-change fields (`allowedChangeTargets`, `comparabilityRank`) are published for Subscriptions; Rating's plan-change inputs are §4.3's `(changeEffectiveAt, changeMode)` plus these frozen enums. +- **Events (W1)**: **all four** `PriceWindowScheduled/Activated/Expired/Cancelled` plus `CatalogVersionPublished` — outbox-emitted; activation-job events at-least-once, idempotency-keyed, ordered per `(tenant, plan)` (pricing design 07 §7). Consuming `Cancelled` is load-bearing: it retracts pre-cached scheduled windows that pricing voids. +- **Snapshot-replay guarantee (W2, T-D-04)**: pricing retains snapshots for open windows; every correction — open-period late arrival and posted-period alike — replays the pinned `pricingSnapshotRef`, never a live catalog read. +- **Bundles (B1, T-D-08)**: for `sum_of_parts` the catalog persists the component `planId` reference set only — **the summing is Rating's at eval**; rev-share arrives publish-normalized (`SUM(effective_share_bp) + platform_cut_bp = 10000` exactly, pricing D-07) and Rating reads **only** `effective_share_bp`, passing it through untouched (evaluated in slice 10). +- **Catalog guarantees relied on (M11)**: open-top tier bands (D-17 — no fail-closed-above-max branch exists); graduated/volume/package restricted to `chargeKind = usage` (D-18); grandfathered rows immutable in price. +- **Publish-side duty (G1, T-D-06)**: Rating registers its four publish-time checks as fail-closed validators inside the pricing Slice 5 approval engine — a single workflow, never a second one (validators owned by slice 10; the registration is part of this contract, PRD §9.2). + +### 4.3 Subscriptions Input Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-subscriptions-input-cc` + +**Direction**: required from Subscriptions ([`../PRD.md`](../PRD.md) §9.2). + +- **`phase_id`** — the active plan phase at `t` (uuid; kind names trial/intro/evergreen are display-only — pricing D-19 via T-D-01); the step-1 input. +- **Eligibility inputs** — `activatedAt` plus the bound cohort **via the pinned price id** in `pricingSnapshotRef` (never `activatedAt` alone — 01 §4.1); step-2 inputs. +- **Seat count / manual quantity** — the quantity behind `per_unit` rows: `quantitySource = subscription_seat_count` supplies the seat count; `quantitySource = manual` supplies an **authored subscription attribute** — both arrive in the same frozen context field from Subscriptions (SEAMS M2). +- **Plan change** — `(changeEffectiveAt, changeMode)`: Rating consumes the pair to pick the split point and rates each side against its own revision and snapshot (PRD §17.2); **the mode-setting policy is Subscriptions' — Rating never decides the mode**. +- **`(currency, region)` binding** — written by Subscriptions into `pricingSnapshotRef` **at activation**, frozen thereafter: the one snapshot segment a third gear writes (§4.7; SEAMS S1). +- **Quote-time (subscription-less) contexts** — e.g. bundle `sum_of_parts` summing at quote — carry none of the above inputs; evaluation defaults to the plan's terminal `phase_id`, `priceEligibility = all_subscriptions`, `cohort = none`, no proration (slice 02 §4.3); the `(currency, region)` pair comes from the quote request rather than an activation-frozen binding, and the quote outcome is an **estimate surface**, not a rateable emission. + +### 4.4 Finance FX Input Contract (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-finance-fx-cc` + +**Direction**: required from Finance ([`../PRD.md`](../PRD.md) §9.2). FX rate tables + lock +policies, versioned by `fxTableVersion` — a member of the determinism tuple +`(window-aggregated inputs, pricingSnapshotRef, fxTableVersion)` (01 §4.2). Two policy modes +(step 8, PRD §17.1): **per-window rate-lock** (final at event time) and **invoice-period FX** +(provisional on the hot path; delta re-rate at period close — close-time `fxTableVersion` +authoritative). Inputs are immutable frozen records; rating-core records `fxTableVersion` / locked-rate +id, and the FX-lock id is a Rating-written snapshot segment (§4.7; slice 07). **No implicit or +provider-default FX exists** — a needed conversion with no policy/table record fails closed (PRD +§7.1, §9.2). + +### 4.5 Promotions Coupon Snapshot Contract (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-promotions-coupons-cc` + +**Direction**: required from Promotions ([`../PRD.md`](../PRD.md) §9.2, §17.2). + +- **Frozen snapshot fields (at minimum)**: `couponId`, `adjustmentType` (`percent | fixed_amount`), `value`, `settlementCurrency` (`price | billing`), `applyPerTierBand`, `applyScope` (`usage | recurring | line_total`, default `line_total`), `stackSequence` (required under `ordered_stack`), validity, applicability filters, redemption eligibility. +- **Stacking**: `exclusive_best` (default) or `ordered_stack` (campaign-linked only); incompatible pairs fail closed at redemption bind (Promotions-side). `settlementCurrency` fixes the slot: price-currency coupons at step 7 before FX; billing-currency coupons after step 8 under the same `fxTableVersion` (PRD §17.1). +- **Duties**: Rating **applies and records** — applied ids + pre/post amounts into the lineage and its snapshot segment — and **never mutates redemption state**; coupon lifecycle, campaigns, and redemption counters are Promotions' (PRD §5.2). Missing `applyScope`, or missing `stackSequence` under `ordered_stack`, fails closed. +- **Open**: the Promotions PRD does not exist yet — field names and the snapshot event contract must align with PRD §17.2 before production coupon rating (PRD §13, §15); the semantics above are the normative Rating-side stub meanwhile. + +### 4.6 Billing periodState and Obligation Contract (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-billing-periodstate-cc` + +**Direction**: bidirectional with Billing ([`../PRD.md`](../PRD.md) §9.2). + +- **Inbound**: `periodState ∈ {open, closed_posted}` for the period covering `t` — a required context field; **missing fails closed**. `open` routes late/corrected usage through pinned-snapshot re-resolution; `closed_posted` engages posted-period, delta-only protection (slice 08). +- **Outbound**: full-precision sub-window amounts plus `PeriodFloorCapObligation` (amount, currency, scope — slice 09 shape; floor/cap set in price currency and converted under the step-8 FX policy, PRD §17.2). +- **Duties**: Billing aggregates the period, executes `max(total, floor)` / `min(total, cap)`, and applies **all** rounding; Rating never rounds and never applies period-level min/max — Billing owns the rounding policy, and the emission records its id (01 §4.4). The non-negative guard runs before floor/cap; a floor never masks a negative line. +- **Opens recorded inline**: the concrete DECIMAL precision of emitted amounts is fixed with Billing in this Design before lock (§1.2); whether a contractual floor claws back coupon discount is a PRD §15 open (default proposal: the floor compares the post-coupon total). + +### 4.7 pricingSnapshotRef Segment Map (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-snapshot-segments-cc` + +At the boundary, the composition contract of 01 §4.3 is a **cross-gear write protocol**: one ref, +three writers, non-overlapping segments; Rating is the composition SoR (SEAMS S1, T-D-03) and +seals the ref at emission — immutable thereafter. The canonical segment table — recorded +identically in 01 §4.3, [`../SEAMS.md`](../SEAMS.md) S1, and here: + +| Segment | Writer | When | +|---------|--------|------| +| `catalogVersion` (pending → committed) | pricing gear | publish / `CatalogVersionPublished` | +| resolved price ids (**incl. `cohort`**) | pricing gear | publish | +| evaluation-policy version | pricing gear | publish | +| `(currency, region)` binding | Subscriptions | activation | +| resolved overlay / `priceOverlay` ids | Rating | evaluation | +| applied coupon id(s) + stacking policy | Rating | evaluation | +| FX-lock id (if any) | Rating | evaluation | +| `commitmentReservation` — reservation match (id, flavor, `reservedQuantity`, resolved rate source); pool set (per-pool id, unit, `poolType`, balance-as-of + `balanceVersion`, draw order, rollover); reserved-vs-pool split | Rating | evaluation | + +Per-writer obligations: pricing pre-stamps its three segments at publish and its catalog-side +view **MUST NOT diverge** from the Rating-composed ref (pricing design 01 §4.4); Subscriptions +writes the binding exactly once, at activation (§4.3); Rating appends its four eval-time +segments and seals. A context whose pre-stamp or binding is missing or torn is rejected +fail-closed — no segment is ever fabricated (01 §4.3). + +**Resolved (2026-07-11, T-D-09)**: the step-6 frozen identifiers form the **eighth named +segment** `commitmentReservation` (row above) — writer Rating @ evaluation (no new writer, no +pricing-side change), recorded identically in [`01-foundation.md`](./01-foundation.md) §4.3, +[`05-commitments-reservations.md`](./05-commitments-reservations.md) §4.1, +[`../SEAMS.md`](../SEAMS.md) S1, and here. The segment's `balanceVersion` is the frozen +balance-sequencing point of T-D-10. + +### 4.8 Canonical Naming (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-canonical-naming-cc` + +SEAMS N1: the canonical name of the upstream catalog gear is **"Pricing (Product Catalog)"**; +"Catalog / Price Book" and bare "Product Catalog" are superseded aliases in this design set. +The pricing gear names this consumer `cpt-cf-bss-pricing-actor-rating` (pricing design 06 §1.3; +renamed to the rating actor with the consolidation — ADR-0002 commit C). On the rating side +([`../PRD.md`](../PRD.md) §2.1): **Rating** names the gear and domain, **rating-core** the pure +evaluation crate, and **Rating / rating-core / rating-core** are deprecated (ADR-0002); "tariff" is +reserved for Pricing-owned rate definitions. A reference through a superseded alias is a +documentation defect, not a second system. + +## 5. Traceability + +- **PRD**: §9.1 (evaluation contract), §9.2 (all six external contracts), §6.1 (`fr-snapshot-carry`, `fr-idempotency`, `fr-separation` — the boundary-visible determinism duties), §17.1 steps 8–9 (FX/emission boundaries), §17.2 (coupon / floor-cap / plan-change boundary contracts), §13 (dependencies), §2.1 (naming), §4.1 (environment constraints). +- **Seams**: C1 (this contract), S1 (segment map), B1 (bundle summing + rev-share pass-through) — owned here; W1 (four events), W2 (snapshot replay), M7 (windowed `Q` key), P1/P2 (enum adoption + CI gate), N1 (naming) surface in these contracts — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-01 (adopted key in the read model), T-D-03 (composition SoR), T-D-04 (replay + counter key), T-D-05 (modelKind set), T-D-06 (validator registration), T-D-07 (enum CI gate), T-D-08 (rev-share pass-through) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md`](../ADR/0001-cpt-cf-bss-rating-adr-scope-key-adoption.md). +- **Pricing design (adopted SoR)**: [`06-consumer-contracts.md`](../../../pricing/docs/design/06-consumer-contracts.md), [`07-pricewindow-linkage.md`](../../../pricing/docs/design/07-pricewindow-linkage.md), [`01-foundation.md`](../../../pricing/docs/design/01-foundation.md), [`08-bundles.md`](../../../pricing/docs/design/08-bundles.md). +- **Related slices**: [`01-foundation.md`](./01-foundation.md) (pipeline, composition, guards), [`05`](./05-commitments-reservations.md)/[`09`](./09-period-plan-change.md) (obligation shapes), [`07`](./07-currency-fx.md) (FX policy), [`08`](./08-retroactivity-corrections.md) (replay), [`10`](./10-governance-asc606.md) (validators + rev-share). diff --git a/gears/bss/rating/docs/design/12-usage-ingestion-normalization.md b/gears/bss/rating/docs/design/12-usage-ingestion-normalization.md new file mode 100644 index 000000000..6faf63004 --- /dev/null +++ b/gears/bss/rating/docs/design/12-usage-ingestion-normalization.md @@ -0,0 +1,328 @@ + + + +# DESIGN — Usage Ingestion & Normalization (Slice 12, pipeline) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-design-usage-ingestion` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Normalization to UsageRecord (normative)](#41-normalization-to-usagerecord-normative) + - [4.2 Continuous-Duration Session Merge (normative)](#42-continuous-duration-session-merge-normative) + - [4.3 Authoritative Usage Dedup (normative)](#43-authoritative-usage-dedup-normative) + - [4.4 Correction Ingestion (normative)](#44-correction-ingestion-normative) + - [4.5 Fail-Closed Intake and Quarantine (normative)](#45-fail-closed-intake-and-quarantine-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **intake edge** of the rating pipeline: it receives raw usage events, normalizes them into +canonical `UsageRecord`s (units, UTC timestamps, tenant axes, subscription/meter linkage, +`dimensionKey` value pass-through from OSS metering), merges contiguous usage of +continuous-duration meters into session/window measures **before** the core's granularity +round-up, and owns the **authoritative usage idempotency/dedup** — the same source event never +contributes to `Q` twice. Correcting/negative usage enters the pipeline here (ingestion + dedup are +pipeline-side; the reversal *math* is the core's — slice [`08`](./08-retroactivity-corrections.md)). + +This slice is deliberately **content-free about price**: it computes no money, resolves no catalog, +and reads no snapshot — it produces the clean, deduplicated, canonical measure the windowed `Q` +store (slice [`13`](./13-q-store-attribution.md)) counts and the core prices. Everything the core's +determinism contract depends on being *frozen* starts life here as a normalized, immutable +`UsageRecord`; a raw event that cannot be normalized is **quarantined, never guessed or dropped** +([`../PRD.md`](../PRD.md) §6.1, §6.7, §9.2; ADR-0002 — this slice absorbs the ingestion half of the +VHP-810 rating-engine scope). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-fr-idempotency` (usage family) | The `UsageDedupStore` is the **authoritative** dedup: a source event keyed `(sourceSystem, meterId, sourceEventId)` yields at most one `UsageRecord` contribution; a duplicate returns the recorded record id and never re-counts into `Q` (§4.3). | +| Merge-before-round-up (PRD §17.1 step 3) | The `SessionMerger` folds contiguous/overlapping intervals of a continuous-duration meter into one session measure **before** the core's granularity round-up — the core prices the merged aggregate, never raw records (§4.2; core slice [`03`](./03-metering-models.md) §4.4). | +| `dimensionKey` value pass-through (PRD §6.7, §17.3) | The `Normalizer` copies `dimensionKey` **values** from OSS metering verbatim into the `UsageRecord`; it never fabricates, defaults, or collapses a dimension value — an undeclared/partial tuple is the core's routing concern (slice [`03`](./03-metering-models.md) §4.2), not an intake guess (§4.1). | +| Correction ingestion (PRD §6.10) | A correcting/negative event is a first-class `UsageRecord` carrying a `corrects` lineage reference; it is **not** a dedup collision — the replay/diff math is core slice [`08`](./08-retroactivity-corrections.md) (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` (≥ 10M ev/day/region) | Ingestion consumer + dedup write path | The write-heavy edge of the gear: a durable at-least-once consumer, partition-local dedup upserts on `(orderingTenantId)` shards, sub-sharded by hash of `subscriptionId`; no catalog read, no core call on the intake path | Load test (slice [`16`](./16-billing-handoff-operations.md) NFR home) | +| `cpt-cf-bss-rating-nfr-resilience` | Dedup + quarantine | At-least-once redelivery is absorbed by the idempotent dedup upsert; an un-normalizable event is quarantined (dead-letter), never silently dropped; replay of the source stream re-derives byte-identical `UsageRecord`s | Chaos/retry test | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partition contract | Dedup and merge stay inside the `(orderingTenantId)` partition and the `(subscription, meter, …)` aggregate; no cross-partition coordination | Design + load test | + +#### Key Decisions + +| Decision | Summary | +|----------|---------| +| Two dedup layers, distinct owners | **Ingestion dedup** (this slice, source-event identity → one `Q` contribution) is separate from **rated-output dedup** (slice [`15`](./15-rated-output-balance-effects.md), usage key → one `RatedCharge`, correction key → one `Adjustment`, T-D-11); the ingestion usage key propagates into the rated-output key so "same key + same snapshot never double-charges" (core slice [`01`](./01-foundation.md) §4.2). | +| T-D-16 scope absorption | This slice is the ingestion half of the consolidated pipeline (ADR-0002); it owns the **first authoritative store** in a gear whose evaluation core owns none. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-ing` + +```text +Raw usage transport (external) OSS metering usage events (at-least-once, durable) · + │ Subscriptions meter↔subscription linkage (frozen ctx) + ▼ +Ingestion edge (this slice) IntakeConsumer · Normalizer · SessionMerger · UsageDedupStore · + │ CorrectionIntake · QuarantineSink + ▼ +Windowed Q store (slice 13) normalized UsageRecords counted into Q (single-writer) +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | The intake consumer, normalization, session merge, dedup, correction intake, quarantine | Rust modules in the `rating` gear (pipeline crate; **not** `rating-core`) | +| Domain | Raw-event and `UsageRecord` shapes, session-merge geometry, dedup/correction keys | Rust; GTS + Rust domain structs | +| Infrastructure | The **usage dedup store** and the append-only `usage_record` store; the quarantine (dead-letter) store | PostgreSQL, SecureORM (`toolkit-db`); durable event transport | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Normalize once, freeze forever + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-normalize-once-ing` + +A raw event becomes exactly one immutable `UsageRecord`; downstream stages (Q, core, output) read +it frozen and never re-normalize. Re-ingesting the same source event replays to the same record — +normalization is a pure function of the raw event plus the frozen unit-identity catalog (§4.1). + +#### Merge before the core rounds + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-merge-before-round-ing` + +Continuous-duration usage is merged into a session/window measure here, **before** the core's +granularity round-up — the core prices the merged aggregate, never raw samples (PRD §17.1 step 3; +core slice [`03`](./03-metering-models.md) §4.4). Merge is deterministic and idempotent (§4.2). + +#### Pass dimensions through, never author them + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-principle-dimension-passthrough-ing` + +`dimensionKey` values are OSS metering's (the external critical path); this slice copies them +verbatim and never fabricates, defaults, or collapses a value. Declaration is the catalog's, freeze +is the core's (slice [`03`](./03-metering-models.md) §4.2) — intake only carries (§4.1). + +### 2.2 Constraints + +#### Authoritative usage dedup + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-authoritative-dedup-ing` + +Usage dedup is authoritative **here** (PRD §6.1 `fr-idempotency`; core slice [`01`](./01-foundation.md) +§4.2): a source event contributes to `Q` at most once. This is distinct from — and upstream of — +the rated-output dedup owned by slice [`15`](./15-rated-output-balance-effects.md). + +#### No price, no snapshot, no catalog + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-no-price-ing` + +Intake computes no money, pins no `pricingSnapshotRef`, and reads no catalog — it produces the +canonical measure; the core does all evaluation over the frozen inputs assembled later (slice +[`14`](./14-unit-synthesis-period-tick.md)). + +#### UTC, canonical units + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-constraint-utc-units-ing` + +All timestamps are UTC; measures carry the meter's canonical unit (unit identity is immutable — +GB ≠ GiB is a different unit, not a conversion; a unit correction is a new unit — aligned with the +registry's deprecate-then-remove doctrine, [`../SEAMS.md`](../SEAMS.md) §I). Intake never converts +units. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-domain-model-ing` + +- **`RawUsageEvent`** — the wire input from OSS metering: source system, `sourceEventId`, meter id, subscription/resource linkage, quantity or start/stop interval, unit, `dimensionKey` values, event time (UTC), optional `corrects` reference. +- **`UsageRecord`** — the normalized, immutable output: `usageKey` (§4.3), tenant axes (`orderingTenantId`, `resourceTenantId`, `payerTenantId`, `sellerTenantId`), subscription + meter + `dimensionKey`, canonical measure, event-time window coordinates, session-merge lineage, `corrects` reference (if any), source lineage. +- **`SessionMeasure`** — for continuous-duration meters: the merged half-open `[start, stop)` interval set and its total measure; the merge lineage (which raw records folded in). +- **`UsageDedupEntry`** — `usageKey` → recorded `UsageRecord` id + digest; the authoritative dedup fact (§4.3). +- **`QuarantineEntry`** — an un-normalizable raw event + typed reason (§4.5); never silently dropped, operator-visible, replayable after remediation. + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-usage-ingestion` + +- **`IntakeConsumer`** — the durable at-least-once transport consumer; hands each `RawUsageEvent` to normalization; commits its transport offset only after the dedup upsert commits (no lost, no double-counted event). +- **`Normalizer`** — `RawUsageEvent` → `UsageRecord`: canonical units, UTC coordinates, tenant-axis + subscription/meter resolution against the frozen context, `dimensionKey` verbatim pass-through; a resolution failure routes to quarantine (§4.5). +- **`SessionMerger`** — folds contiguous/overlapping continuous-duration intervals into a `SessionMeasure` before round-up (§4.2). +- **`UsageDedupStore`** — the authoritative dedup upsert on `usageKey`; returns the recorded id on a duplicate (§4.3). +- **`CorrectionIntake`** — admits correcting/negative events as first-class `UsageRecord`s with a `corrects` reference; routes the replay/diff to core slice [`08`](./08-retroactivity-corrections.md) via the Q store re-materialization (slice [`13`](./13-q-store-attribution.md)) (§4.4). +- **`QuarantineSink`** — the dead-letter store for un-normalizable events (§4.5). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-ingest-ing` + +**Inbound (consumed)**: the OSS metering usage transport — at-least-once, durable, ordered per +source partition; the raw-event schema is an OSS-metering contract this slice adopts. **Outbound +(provided in-process to slice [`13`](./13-q-store-attribution.md))**: `UsageRecord`s (new and +correcting) with their `usageKey`, ready to count into `Q`. No external synchronous API — intake is +transport-driven; the Subscriptions meter↔subscription linkage arrives as frozen context. + +### 3.4 Internal Dependencies + +Downstream: slice [`13`](./13-q-store-attribution.md) (counts `UsageRecord`s into `Q`), core slice +[`03`](./03-metering-models.md) (prices the merged aggregate), core slice +[`08`](./08-retroactivity-corrections.md) (correction replay math). Boundary/context contracts: +slice [`11`](./11-consumer-contracts.md) (Subscriptions input; the frozen linkage). No dependency on +`rating-core` internals — intake precedes evaluation. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| OSS Metering (Usage Collector) | raw usage events (quantity/interval, unit, `dimensionKey` values, event time), at-least-once durable | PRD §6.7, §17.3; the external critical path. **The built v1 collector has no emission surface (pull/query only)** — the transport, attribution join, dedup-key derivation, and correction visibility are tracked as [`../SEAMS.md`](../SEAMS.md) §J (UC1–UC6; UC6 = temporal shape — v1 carries point-stamped `(value, created_at)` only, duration rides chunked counter deltas until a native interval kind lands); **UC1 gates this slice's implementation** | +| Subscriptions | meter ↔ subscription linkage, tenant axes, the pinned `orderingTenantId` | slice [`11`](./11-consumer-contracts.md) §4.3; SEAMS S1 / SUB-R1 | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-ingest-ing` + +**Ingest one usage event**: + +1. `IntakeConsumer` receives a `RawUsageEvent` from the durable transport. +2. `Normalizer` resolves tenant axes + subscription/meter linkage and produces a canonical `UsageRecord` (UTC, canonical unit, `dimensionKey` verbatim); an unresolvable event routes to `QuarantineSink` (§4.5) and the offset still commits (poison message never blocks the partition). +3. `UsageDedupStore` upserts on `usageKey`: a first sighting persists the `UsageRecord`; a duplicate returns the recorded id and stops (no re-count). +4. For continuous-duration meters, `SessionMerger` folds the record into its open session measure (§4.2) — idempotently, keyed by the same `usageKey` set. +5. The transport offset commits **after** the dedup upsert; the `UsageRecord` is now visible to the Q store (slice [`13`](./13-q-store-attribution.md)). + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-flow-correction-intake-ing` + +**Correction intake**: a correcting/negative event carries a `corrects` reference; it is admitted +as a new `UsageRecord` (its own `usageKey`), links to the corrected record, and signals slice +[`13`](./13-q-store-attribution.md) to re-materialize the affected window `Q` — the reversal math +runs in core slice [`08`](./08-retroactivity-corrections.md). A correction is never a dedup +collision with the record it corrects. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-ingestion-ing` + +**Owned (the gear's first authoritative stores; partitioned by the pinned `orderingTenantId`, UTC):** + +- `usage_record` — append-only normalized records; indexed by `(subscription, meter, dimensionKey, event-time window)` to feed the Q store; immutable. +- `usage_dedup` — unique index on `usageKey`; the authoritative ingestion dedup fact. +- `usage_quarantine` — dead-letter store: raw event + typed reason + remediation/replay status. + +Concrete DDL is Design; the append-only + immutability posture mirrors the core's frozen-input +doctrine. No monetary column (the gear computes no money — core slice [`01`](./01-foundation.md) §3.7). + +#### Hot/cold tiering of `usage_record` (storage posture) + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-storage-tiering-ing` + +`usage_record` is tiered; the guarantees are tier-invariant. **Hot** (PostgreSQL): open windows +plus a trailing K months past `closed_posted` (K is deployment-tuned; correction traffic decays +fast). **Cold**: an **S3-compatible object store** holding immutable, digest-verified columnar +objects (Parquet) to the full ≥ 7-year correction horizon (core slice +[`08`](./08-retroactivity-corrections.md) §4.1), partitioned by `(orderingTenantId, window)` and +sorted `(subscription, meter, eventTime)` for partition/row-group pruning. A per-shard **archiver** +job (coordination lease, same sharding line as the other pipeline jobs) rewrites hot rows into +objects, **verifies row count + content digest against the manifest, and only then prunes hot** — +never an unverified delete. The `usage_archive_manifest` table (partition → object keys, row +count, digest) is authoritative state and is backed up with the hot store; loss is repaired by +bucket listing + digest re-verification. + +Cold is read **only off the hot path** — three consumers: correction-lane re-materialization +(slice [`13`](./13-q-store-attribution.md) §4.4 reads the contributing record set), dispute/audit +evidence, and DR rebuild. A fetch hydrates the partition (optionally into a bounded-TTL +rehydration cache so a cascade over the same window fetches once); immutable objects + the +recorded digest preserve the determinism contract across tiers (same record set ⇒ same `Q`). +Object-store unavailability backpressures the correction lane fail-closed; **first rating never +reads cold**. Tiering applies to `usage_record` only: `usage_dedup` stays hot forever (tiny rows, +unbounded window — archiving it would silently shrink the dedup guarantee), and +`usage_quarantine` follows its remediation lifecycle. **Pinned here**: the contract (S3-compatible +API, object immutability/lock, digest verification, the partition layout, cold-reads-only-off-hot-path). +**Deployment profile**: the concrete endpoint (e.g. VHI S3 / MinIO / cloud S3) and K. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-ing` + +Pipeline crate inside the one `rating` gear deployable (ADR-0002; distinct from the `rating-core` +crate). The `IntakeConsumer` runs as a **partitioned consumer group**: one consumer per +`orderingTenantId` shard, sub-sharded by hash of `subscriptionId` for a large tenant (the same +sharding rule the subscriptions gear applies to its jobs), so the ≥ 10M ev/day/region write edge is +never funnelled through one worker; dedup upserts are partition-local. Backpressure is the durable +transport's (unacked offsets), never a silent drop. + +## 4. Additional Context + +### 4.1 Normalization to UsageRecord (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-normalization-ing` + +- Normalization is a **pure function** of `(RawUsageEvent, frozen unit-identity + subscription/meter linkage)`: replay of the source stream re-derives byte-identical `UsageRecord`s. +- `dimensionKey` **values** are copied verbatim from OSS metering; intake never fabricates, defaults, or collapses a value (declaration = catalog, freeze = core slice [`03`](./03-metering-models.md); PRD §6.7). +- Units are canonical and immutable (GB ≠ GiB); intake performs **no** unit conversion — a wrong unit is a quarantine, not a silent coercion (§4.5). +- **Gauge samples (D-44 / T-D-17)**: for a meter whose frozen `aggregationFunction ≠ sum`, records are point-stamped **level samples** (collector `gauge` kind) in the meter's **level unit** (GB, cloudlet — validated at publish against the billable level·granule unit). Intake normalizes and dedups them exactly like counter deltas — it never folds, never integrates, never fills gaps (the granule fold is the Q store's, slice [`13`](./13-q-store-attribution.md); the `hold_last`/`maxHold` gap policy is applied at fold time, not at intake). +- Every `UsageRecord` carries the full tenant-axis set and the pinned `orderingTenantId` for partition alignment with the core's determinism key and the subscriptions ordering key (SEAMS S1 / SUB-R1). + +### 4.2 Continuous-Duration Session Merge (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-session-merge-ing` + +- For a continuous-duration meter, contiguous or overlapping `[start, stop)` intervals for the same `(subscription, meter, dimensionKey)` are merged into one **session measure** before the core's granularity round-up (PRD §17.1 step 3): twelve 5-minute samples become one measured span, not twelve rounded units (core slice [`03`](./03-metering-models.md) §4.4). +- Merge is **deterministic and idempotent**: overlapping intervals union (no double-count of an overlap); a re-delivered interval folds to the same session by `usageKey`; half-open UTC geometry means a boundary instant belongs to exactly one interval. +- The merge lineage (which raw records folded in) is retained so a correction to one contributing record re-materializes the session deterministically (slice [`13`](./13-q-store-attribution.md), core slice [`08`](./08-retroactivity-corrections.md)). +- Discrete / `per_event` meters are **not** merged — each event is its own measure (core slice [`01`](./01-foundation.md) §4.2 `per_event` unit). + +### 4.3 Authoritative Usage Dedup (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-usage-dedup-ing` + +- The **usage key** is `(sourceSystem, meterId, sourceEventId)`; where a source cannot supply a stable event id, a content digest over the canonical fields is the fallback key (recorded so the choice is auditable). Dedup is a unique-index upsert: a first sighting persists, a duplicate returns the recorded `UsageRecord` id and **does not** re-count into `Q`. +- Dedup is **authoritative here** — the single source of truth that a usage event contributes to `Q` at most once (PRD §6.1; core slice [`01`](./01-foundation.md) §4.2). This is a **distinct layer** from the rated-output dedup (slice [`15`](./15-rated-output-balance-effects.md): `RatedCharge` per usage key, `Adjustment` per correction key, T-D-11); the ingestion `usageKey` **propagates** into the rated-output key so the two layers compose ("same key + same snapshot never double-charges"). +- At-least-once transport redelivery, consumer restart, and source-stream replay are all absorbed by the idempotent upsert — none double-counts. + +### 4.4 Correction Ingestion (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-correction-intake-ing` + +- A correcting or negative usage event is admitted as a **first-class `UsageRecord`** with its own `usageKey` and a `corrects` lineage reference; it is **never** a dedup collision with the record it adjusts. +- Correction ingestion stays pipeline-side (PRD §6.10); the record signals slice [`13`](./13-q-store-attribution.md) to **re-materialize** the affected window `Q`, and the replay/diff/reversal math runs in core slice [`08`](./08-retroactivity-corrections.md) over the pinned snapshot — intake carries the corrected measure, never computes the delta. +- Late arrival vs `periodState` (`open` / `closed_posted`) is **not** decided here: intake tags event time; routing to open-period re-resolution or posted-period protection is the core's (slice [`08`](./08-retroactivity-corrections.md) §4.3), driven by the Billing `periodState` assembled in slice [`14`](./14-unit-synthesis-period-tick.md). + +### 4.5 Fail-Closed Intake and Quarantine (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-normative-quarantine-ing` + +- An event that cannot be normalized — unknown meter, unresolvable subscription linkage, non-canonical/unknown unit, malformed interval — is routed to `usage_quarantine` with a **typed reason**; it is **never silently dropped and never guessed** (the fail-closed doctrine of core slice [`01`](./01-foundation.md) §2.1). +- Quarantine is operator-visible and **replayable**: after the upstream defect is fixed (e.g. the meter is declared, the subscription linkage lands), the event replays through normalization deterministically. +- A poison event never blocks its partition: the transport offset commits after quarantine so the consumer group makes progress; correctness is preserved because a quarantined event contributes nothing to `Q` until replayed. + +## 5. Traceability + +- **PRD**: §9.2 (Rating handoff duties — usage dedup, merge), §6.1 (`fr-idempotency` usage family), §6.7 (`dimensionKey` values), §6.10 (correction ingestion), §17.1 step 3 (merge-before-round-up), §7.1 (throughput NFR). +- **Seams**: M7 (feeds the single-writer Q store), S1 / SUB-R1 (pinned `orderingTenantId`), **UC1–UC6 §J** (Usage Collector ingestion bridge — transport, watermark, attribution join, dedup-key derivation, correction visibility, temporal shape; UC1 gates implementation) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-04 (counter key the records feed), T-D-11 (rated-output dedup is a distinct downstream layer), T-D-16 (consolidation; this slice absorbs VHP-810 ingestion scope) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). +- **Related slices**: [`13-q-store-attribution.md`](./13-q-store-attribution.md) (counts the records), [`01-foundation.md`](./01-foundation.md) / [`03-metering-models.md`](./03-metering-models.md) / [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (core consumers), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (Subscriptions input). diff --git a/gears/bss/rating/docs/design/13-q-store-attribution.md b/gears/bss/rating/docs/design/13-q-store-attribution.md new file mode 100644 index 000000000..72dee6fe3 --- /dev/null +++ b/gears/bss/rating/docs/design/13-q-store-attribution.md @@ -0,0 +1,291 @@ + + + +# DESIGN — Windowed Q Store & Attribution (Slice 13, pipeline) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-design-q-store` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 The Windowed Counter and Its Key (normative)](#41-the-windowed-counter-and-its-key-normative) + - [4.2 Single-Writer Serialization and Q Versioning (normative)](#42-single-writer-serialization-and-q-versioning-normative) + - [4.3 Per-Slice Attribution and bandOffsetQ (normative)](#43-per-slice-attribution-and-bandoffsetq-normative) + - [4.4 Re-Materialization and Reversal Decrement (normative)](#44-re-materialization-and-reversal-decrement-normative) + - [4.5 Composite Input-Q Assembly (normative)](#45-composite-input-q-assembly-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **single writer** of the windowed tier counter `Q` per +`(subscription, meter, dimensionKey, window)` (SEAMS **M7** — the counter key doubles as the +partition key). It materializes `Q` from the normalized `UsageRecord`s of slice +[`12`](./12-usage-ingestion-normalization.md); maintains the **per-slice attribution and +`bandOffsetQ`** when a period split partitions an aggregation window (T-D-12); re-materializes `Q` +on late/corrected usage before core replay; realizes the **reversal counter decrement** (the core +never writes a counter); and assembles mutually consistent input-`Q` tuples for composite meters. + +The store is the load-bearing reason the core can be a pure function: it converts an unbounded +stream of raw usage into a small set of **frozen, versioned** aggregate values the core prices +deterministically. Two invariants make that safe — **exactly one writer per counter key** (so no +interleaving corrupts an aggregate) and **an explicit `qVersion`** on every counter (so the frozen +determinism tuple binds a *specific* `Q`, and a re-materialization is a new version that triggers +re-resolution rather than a silent mutation). The core aggregates nothing and mutates no counter +([`../PRD.md`](../PRD.md) §9.2, §6.5; core slice [`01`](./01-foundation.md) §4.2). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| Windowed `Q` per `(subscription, meter, dimensionKey, window)` (SEAMS M7, T-D-04) | `QMaterializer` is the **single writer** per key; `Q` is the window-aggregated measure the core's windowed models price; the key **is** the partition key — window math stays partition-local (§4.1). | +| Per-slice attribution + `bandOffsetQ` (T-D-12) | When a slice-[`09`](./09-period-plan-change.md) split partitions an open window, `SliceAttributor` attributes each sub-window's `Q_slice` by event time and derives the frozen `bandOffsetQ` (accumulated prior-slice `Q`) the core consumes (§4.3). | +| Late/corrected usage re-materialization (PRD §6.10) | `QMaterializer` recomputes the affected window `Q` (and the per-slice attribution) from the current `UsageRecord` set, bumps `qVersion`, and signals slice [`14`](./14-unit-synthesis-period-tick.md) to route re-resolution; the decrement of a reversal is realized here, not by the core (§4.4). | +| Composite input-`Q` assembly (core slice [`03`](./03-metering-models.md) §3.6) | `CompositeAssembler` reads the ≥ 2 input-meter `Q`s as a **version-consistent frozen tuple** across partitions (§4.5); the input-join rule for dimension-carrying inputs stays a tracked open. | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Single-writer partition contract | One writer per `(subscription, meter, dimensionKey, window)`; **zero cross-partition locks**; unrelated counters materialize in parallel; a large tenant sub-shards by hash of `subscriptionId` (§3.8) | Design + load test (slice [`16`](./16-billing-handoff-operations.md)) | +| `cpt-cf-bss-rating-nfr-throughput-latency` | Incremental materialization | The common path is an incremental counter increment keyed by the partition; full re-materialization is the off-hot-path correction case only | Load test | +| `cpt-cf-bss-rating-nfr-resilience` | `qVersion` + idempotent increment | A re-delivered `UsageRecord` (already deduped in slice 12) never double-increments; a crashed materializer resumes deterministically from the `UsageRecord` set; the frozen `qVersion` makes replay detectable | Chaos/retry test | + +#### Key Decisions + +| Decision | Summary | +|----------|---------| +| `qVersion` per counter | Every counter carries a monotonic `qVersion`; slice [`14`](./14-unit-synthesis-period-tick.md) freezes a specific `qVersion` into the determinism tuple, so a re-materialization is a new version + a re-resolution trigger, never an in-place change to an already-rated aggregate (T-D-04 replay discipline). | +| Window identity is derived, not authored | The `window` coordinate is computed from event time under the subscription's **frozen** `tierAggregationWindow` + `billingAnchorPolicy` (D-20 clamp) using the same `AnchorCalendar` math as core slice [`09`](./09-period-plan-change.md); the store applies the calendar, never invents boundaries. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-qst` + +```text +Normalized usage (slice 12) UsageRecords (new + correcting), deduped, canonical + ▼ +Q store (this slice) QMaterializer · WindowResolver · SliceAttributor · + │ (single writer / key) CompositeAssembler · ReversalDecrementer + ▼ +Frozen inputs to the core versioned Q + bandOffsetQ (per unit), via slice 14 context assembly +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Counter materialization, window resolution, per-slice attribution, composite assembly, reversal decrement | Rust modules in the `rating` gear (pipeline crate; **not** `rating-core`) | +| Domain | Counter, per-slice attribution, `bandOffsetQ`, `qVersion`, composite input-tuple shapes | Rust; GTS + Rust domain structs | +| Infrastructure | The **`Q` store** (windowed counters + per-slice attribution rows + `qVersion`) | PostgreSQL, SecureORM (`toolkit-db`) | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### One writer per counter key + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-single-writer-qst` + +Exactly one writer materializes `Q` for a given `(subscription, meter, dimensionKey, window)` — the +load-bearing invariant already normative in the core (slice [`01`](./01-foundation.md) §4.2; PRD +§7.1). No interleaving of increments across writers is possible, so no lock and no cross-partition +coordination is needed on the counter path. + +#### Frozen and versioned, never silently mutated + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-versioned-q-qst` + +A counter the core rated was rated at a specific `qVersion`; a later change re-materializes to a +**new** version and triggers re-resolution (slice [`08`](./08-retroactivity-corrections.md)) — the +already-rated `Q` value is never edited under the core's feet. Determinism depends on the frozen +tuple binding one `qVersion` (§4.2). + +#### Attribute by event time, price by slice + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-attribute-by-time-qst` + +When a window is split, `Q` is attributed to sub-window slices by **event time**, and each slice's +`bandOffsetQ` is the accumulated prior-slice `Q`; the core prices each slice as its own unit over +its own pin (T-D-12). Attribution is the store's; band math is the core's (§4.3). + +### 2.2 Constraints + +#### The core never writes Q + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-core-never-writes-qst` + +The evaluation core aggregates nothing and mutates no counter; the reversal decrement, the +re-materialization, and the per-slice attribution are all this slice's (PRD §9.2; core slice +[`03`](./03-metering-models.md) §4.3). The core reads `Q` frozen. + +#### Window identity from the frozen calendar + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-window-from-calendar-qst` + +The `window` coordinate derives from event time under the subscription's frozen +`tierAggregationWindow` + `billingAnchorPolicy` (D-20 clamp) via the core slice +[`09`](./09-period-plan-change.md) `AnchorCalendar` math; the store never authors a boundary and +never uses a live/mutable anchor. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-qst` + +- **`WindowedCounter`** — `(subscription, meter, dimensionKey, window)` → aggregated `Q` + `qVersion` (monotonic) + the contributing `usageKey` set (for deterministic re-materialization). +- **`WindowCoordinate`** — the resolved window identity: `tierAggregationWindow` kind + concrete half-open UTC `[from, to)` boundaries from the frozen anchor (D-20). +- **`SliceAttribution`** — for a split window: per sub-window `[from, to)` → `Q_slice` + the frozen `bandOffsetQ` (accumulated prior-slice `Q`); the ordered slice list of the window. +- **`CompositeInputTuple`** — for a composite meter: the ≥ 2 input-meter `Q`s read at a mutually consistent `qVersion` set, plus the assembly watermark (§4.5). +- **`ReMaterializationSignal`** — emitted to slice [`14`](./14-unit-synthesis-period-tick.md) when a counter or attribution changes: the affected units + the new `qVersion` + the correction lineage. + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-q-store` + +- **`QMaterializer`** — the single-writer counter path: increments (new usage) or recomputes (correction) `Q` for a key from the `UsageRecord` set; bumps `qVersion` on any change. +- **`WindowResolver`** — computes the `WindowCoordinate` for a `UsageRecord` from event time under the frozen anchor (`AnchorCalendar` math reused from core slice [`09`](./09-period-plan-change.md)). +- **`SliceAttributor`** — maintains `SliceAttribution` + `bandOffsetQ` when a period split partitions the window (T-D-12); recomputes offsets when an earlier slice's `Q` changes (§4.3). +- **`CompositeAssembler`** — reads the version-consistent input-`Q` tuple for a composite meter across partitions (§4.5). +- **`ReversalDecrementer`** — realizes the counter decrement for a correcting/negative `UsageRecord` by recomputing the window `Q` (§4.4). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-q-store-qst` + +**Inbound (from slice [`12`](./12-usage-ingestion-normalization.md))**: normalized `UsageRecord`s +(new + correcting) to count. **Outbound (to slice [`14`](./14-unit-synthesis-period-tick.md))**: the +frozen `(Q, qVersion)` and, for split windows, the per-slice `(Q_slice, bandOffsetQ)`; plus +`ReMaterializationSignal`s that drive cascade routing. The core reads these only through the frozen +`EvaluationContext` slice 14 assembles — never a direct counter read on the hot path. + +### 3.4 Internal Dependencies + +Upstream: slice [`12`](./12-usage-ingestion-normalization.md) (the `UsageRecord`s counted). +Downstream: slice [`14`](./14-unit-synthesis-period-tick.md) (freezes `Q`/`qVersion`/`bandOffsetQ` +into the context and routes cascades), core slice [`03`](./03-metering-models.md) (prices the +windowed aggregate + reads `bandOffsetQ`), core slice [`08`](./08-retroactivity-corrections.md) +(re-resolves on re-materialization). Reuses core slice [`09`](./09-period-plan-change.md) +`AnchorCalendar` as pure window math. + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Subscriptions (via slice 11 context) | `tierAggregationWindow` + `billingAnchorPolicy` per subscription (for window resolution); split boundaries (`changeEffectiveAt`, phase conversions) | slice [`11`](./11-consumer-contracts.md) §4.3; SEAMS P2 | + +_No direct external transport — this slice consumes slice 12's output in-process and serves slice 14._ + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-materialize-qst` + +**Materialize on new usage**: + +1. A `UsageRecord` arrives from slice [`12`](./12-usage-ingestion-normalization.md). +2. `WindowResolver` computes its `WindowCoordinate` from event time under the frozen anchor. +3. `QMaterializer` (single writer for the key) folds the record's measure into `Q`, appends the `usageKey` to the contributing set, and bumps `qVersion`. +4. If the window is split, `SliceAttributor` attributes the measure to the owning sub-window slice and updates that slice's `Q_slice` (and, if it shifts a later slice's start offset, its `bandOffsetQ`). +5. The updated `(Q, qVersion)` (and per-slice values) become available to slice [`14`](./14-unit-synthesis-period-tick.md) for context assembly. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-rematerialize-qst` + +**Re-materialize on correction**: + +1. A correcting/negative `UsageRecord` (slice [`12`](./12-usage-ingestion-normalization.md) §4.4) references the affected window. +2. `ReversalDecrementer` / `QMaterializer` recompute `Q` (and per-slice `Q_slice`/`bandOffsetQ`) from the **current** `UsageRecord` set — the decrement is realized here — and bump `qVersion`. +3. A `ReMaterializationSignal` (affected units + new `qVersion` + correction lineage) goes to slice [`14`](./14-unit-synthesis-period-tick.md), which routes `reresolve` through the core (slice [`08`](./08-retroactivity-corrections.md)); a `bandOffsetQ` shift cascades to later slices of the same window (§4.3). + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-q-qst` + +**Owned (partitioned by the pinned `orderingTenantId`, UTC):** + +- `windowed_counter` — PK `(subscription, meter, dimensionKey, window)`; columns: aggregated `Q`, `qVersion` (monotonic), contributing-`usageKey` set (or a digest + a link to slice 12's `usage_record`), last-materialized watermark. **Single-writer** per PK. +- `slice_attribution` — for split windows: `(subscription, meter, dimensionKey, window, slice)` → `Q_slice`, `bandOffsetQ`, slice `[from, to)`; ordered per window. + +Concrete DDL is Design. The store is authoritative aggregate state (it survives supersession per M7); +no monetary column (the gear computes no money — core slice [`01`](./01-foundation.md) §3.7). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-qst` + +Pipeline crate inside the one `rating` gear deployable. Single-writer ownership is realized by +**partition ownership**: the counter key hashes to a shard, one materializer instance owns a shard +at a time (coordination lease library), so "single writer per key" holds without a per-row lock; +shards are `orderingTenantId`-scoped and sub-sharded by hash of `subscriptionId` for a large tenant. +Unrelated shards materialize fully in parallel — zero cross-partition locks (PRD §7.1). + +## 4. Additional Context + +### 4.1 The Windowed Counter and Its Key (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-counter-key-qst` + +- The counter key is **`(subscription, meter, dimensionKey, window)`** (SEAMS M7, the superset key — per-subscription reset scope + per-dimension counters; T-D-04); it doubles as the **partition key**, so single-meter window math is partition-local by construction. +- The `window` coordinate is derived from event time under the subscription's frozen `tierAggregationWindow ∈ {calendar_month, invoice_period, subscription_lifetime, per_event}` and, for `invoice_period`, the frozen `billingAnchorPolicy` (D-20 no-drift clamp) — the same `AnchorCalendar` math as core slice [`09`](./09-period-plan-change.md); the store never authors a boundary. +- For `per_event` there is no accumulation — the "counter" is the single event's measure; windowed models (`graduated`/`volume`/`package`) require a non-`per_event` window (core slice [`03`](./03-metering-models.md) §4.3). +- **Non-`sum` folds (D-44 / T-D-17)**: for a meter with frozen `aggregationFunction ∈ {peak, time_weighted}`, `QMaterializer` maintains **per-granule partials** under the counter key (granule = the frozen `aggregationGranularity` cut of the window): `peak` keeps the granule max, `time_weighted` the granule step-integral (`hold_last` bounded by `maxHold`, beyond → 0 + operator signal). The window `Q` is the **sum of granule folds** — additive, so the single-writer key, `qVersion` discipline, and slice attribution apply unchanged. A late/corrected sample **re-folds only its granule** and bumps `qVersion` — the standard §4.4 re-materialization, scoped to one granule's contribution. +- Supersession does **not** reset an in-window counter (the new catalog row's bands apply to the continued `Q`, pricing `inst-tb-window-continuity`); the counter's scope is the window, not the catalog revision (§4.3). + +### 4.2 Single-Writer Serialization and Q Versioning (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-single-writer-qst` + +- Exactly **one writer per counter key** (PRD §7.1; core slice [`01`](./01-foundation.md) §4.2): realized by partition ownership (§3.8), so concurrent increments for one key are serialized without a lock and unrelated keys never contend. +- Every counter carries a monotonic **`qVersion`**, bumped on any materialization (increment or recompute). Slice [`14`](./14-unit-synthesis-period-tick.md) freezes a specific `(Q, qVersion)` into the determinism tuple; the core rates that version. A later change produces a **new** `qVersion` and a `ReMaterializationSignal` — never an in-place edit of the value a prior rating observed (the T-D-04 replay discipline realized on the write side). +- Ingestion has already deduped the source events (slice [`12`](./12-usage-ingestion-normalization.md) §4.3), so a re-delivered `UsageRecord` never double-increments; a crashed materializer resumes by recomputing from the contributing `usageKey` set — deterministic, not offset-fragile. + +### 4.3 Per-Slice Attribution and bandOffsetQ (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-slice-attribution-qst` + +- When a slice-[`09`](./09-period-plan-change.md) split point (window activation/supersession, phase conversion, plan change) falls **inside** an open aggregation window, the window stays the counter scope but each sub-window slice becomes its own evaluation unit (T-D-12; core slice [`01`](./01-foundation.md) §4.2). The store attributes each `UsageRecord`'s measure to the sub-window that contains its **event time** and maintains, per slice, `Q_slice` and the frozen **`bandOffsetQ`** = the accumulated `Q` of all prior slices of the same window. +- Continuity is not configurable at window-activation/supersession and phase-conversion boundaries (they always carry — `bandOffsetQ` = accumulated prior-slice `Q`); only a **plan-change** boundary consults the snapshot-frozen carry-vs-reset flag (`reset` ⇒ `bandOffsetQ = 0`) routed by slice [`09`](./09-period-plan-change.md) (core slice [`03`](./03-metering-models.md) §4.3). +- A change to an earlier slice's `Q` **shifts** every later slice's `bandOffsetQ`; the store recomputes the offsets in slice order and emits `ReMaterializationSignal`s for the affected later slices, which slice [`14`](./14-unit-synthesis-period-tick.md) routes as the T-D-12 cascade (each later slice re-resolves under its **own** pin — core slice [`08`](./08-retroactivity-corrections.md) §4.4). The cascade is finite and strictly ordered by slice index. + +### 4.4 Re-Materialization and Reversal Decrement (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-rematerialize-qst` + +- Late or corrected usage recomputes the affected window `Q` (and per-slice attribution) from the **current** `UsageRecord` set — the reversal counter **decrement is realized here**, by recomputation, not by a core write (core slice [`03`](./03-metering-models.md) §4.3, slice [`08`](./08-retroactivity-corrections.md) §4.4). For an archived window the set is served from the cold tier (slice [`12`](./12-usage-ingestion-normalization.md) §3.7 tiering note) — identical recompute, correction-lane latency only. +- Re-materialization bumps `qVersion` and emits a `ReMaterializationSignal`; the core's `reresolve` then diffs the new rating against the prior rated version and emits delta-only adjustments (slice [`15`](./15-rated-output-balance-effects.md)). The counter recompute is deterministic — same `UsageRecord` set ⇒ same `Q`. +- Re-materialization is **off the hot path** (correction lane); the normal path is an incremental increment. A window whose `periodState = closed_posted` still re-materializes its `Q`, but the core routes the diff through posted-period protection (delta-only) — the counter recompute is identical either way (slice [`08`](./08-retroactivity-corrections.md) §4.3). + +### 4.5 Composite Input-Q Assembly (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-composite-assembly-qst` + +- A composite (derived) meter reads its ≥ 2 input-meter `Q`s from **different** partitions; the store assembles them as a **version-consistent frozen tuple** — each input `Q` pinned at a specific `qVersion`, captured at one assembly watermark — so the core reads frozen values, never live counters, and no cross-partition lock is taken (core slice [`03`](./03-metering-models.md) §3.6). +- A later change to **any** input `Q` (new `qVersion`) re-assembles the tuple and re-resolves the composite line under the slice-[`08`](./08-retroactivity-corrections.md) correction keys; the composite line partitions on `(subscription, outputUnit, dimensionKey, window)`. +- **Open**: the input-join rule when composite inputs carry dimension values (join on the matching `dimensionKey` tuple vs a formula-declared join) MUST be pinned jointly with the pricing gear before composite and dimensional pricing co-occur — tracked in core slice [`03`](./03-metering-models.md) §3.6 / [`../SEAMS.md`](../SEAMS.md); at launch they do not co-occur (`dimensionKey` empty until OSS emission). + +## 5. Traceability + +- **PRD**: §9.2 (windowed `Q` duties), §6.5 (`tierAggregationWindow`), §6.10 (re-materialization on correction), §17.1 "Determinism and Rating compatibility", §7.1 (single-writer / horizontal-scale NFR). +- **Seams**: M7 (writer side — the counter/partition key) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-04 (counter key + snapshot-replay), T-D-12 (per-slice attribution + `bandOffsetQ`), T-D-16 (consolidation) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). +- **Related slices**: [`12-usage-ingestion-normalization.md`](./12-usage-ingestion-normalization.md) (feeds records), [`14-unit-synthesis-period-tick.md`](./14-unit-synthesis-period-tick.md) (freezes `Q`/routes cascades), [`03-metering-models.md`](./03-metering-models.md) §4.3 (band-offset math), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (re-materialization + cascades), [`09-period-plan-change.md`](./09-period-plan-change.md) (`AnchorCalendar`, split boundaries). diff --git a/gears/bss/rating/docs/design/14-unit-synthesis-period-tick.md b/gears/bss/rating/docs/design/14-unit-synthesis-period-tick.md new file mode 100644 index 000000000..894e46d94 --- /dev/null +++ b/gears/bss/rating/docs/design/14-unit-synthesis-period-tick.md @@ -0,0 +1,300 @@ + + + +# DESIGN — Unit Synthesis & Period Tick (Slice 14, pipeline) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-design-unit-synthesis` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 The Three Evaluation-Unit Kinds (normative)](#41-the-three-evaluation-unit-kinds-normative) + - [4.2 The Period Tick (normative)](#42-the-period-tick-normative) + - [4.3 Frozen-Context Assembly and Pin Discipline (normative)](#43-frozen-context-assembly-and-pin-discipline-normative) + - [4.4 Cascade Routing, Coalescing, and Bounding (normative)](#44-cascade-routing-coalescing-and-bounding-normative) + - [4.5 Commitment-Balance Freezing and the Hot Path (normative)](#45-commitment-balance-freezing-and-the-hot-path-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **conductor** between the operational stores and the pure core: it synthesizes the **three +evaluation-unit kinds** (`per_event`; windowed `Q` per sub-window slice; **period-driven** units for +recurring lines, capacity-flavor charges, and true-up surfacing), assembles a fully **frozen** +`EvaluationContext` for each, and invokes the core's `evaluate()` / `reresolve()`. It owns the +**period tick** (T-D-15): at every `AnchorPeriod` boundary it emits the period-driven units — a +zero-usage period still bills its capacity and recurring lines. And it owns **cascade routing**: a +balance-affecting correction (T-D-10) or a `bandOffsetQ` shift (T-D-12) re-enters the core as +delta-only `reresolve`, coalesced and bounded on a correction lane. + +This slice is the one place the whole gear's frozen-input discipline is *enforced*: nothing reaches +`rating-core` except through a context this slice sealed — read-model pinned to one committed +`CatalogVersion`, `Q` pinned to a `qVersion`, commitment balances pinned to a `balanceVersion`, FX +to an `fxTableVersion`, coupons to a snapshot, `periodState` from Billing. A missing input fails +closed **at this boundary**, never inside the core (core slice [`01`](./01-foundation.md) §2.1, slice +[`11`](./11-consumer-contracts.md) §2.1). Because balances are **frozen into the context**, the core +hot path is partition-local and never waits on a live cross-unit balance — the cross-unit ordering +lives on the asynchronous write-back (slice [`15`](./15-rated-output-balance-effects.md)), off the +hot path (§4.5). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| Three unit kinds (core slice [`01`](./01-foundation.md) §4.2) | `UnitSynthesizer` produces `per_event` units (one `UsageRecord`), windowed-`Q` units (per sub-window slice, carrying frozen `bandOffsetQ`), and period-driven units keyed `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)` (§4.1). | +| Period tick (T-D-15) | `PeriodTick` fires at each `AnchorPeriod` boundary and synthesizes the period-driven units; **idempotent per `(subscription, AnchorPeriod)`**; a zero-usage period still emits its capacity/recurring units (§4.2). | +| Frozen-context assembly + pin discipline (core slice [`11`](./11-consumer-contracts.md) §4.2) | `ContextAssembler` pins one committed `CatalogVersion` (published + warm-completion marker; pin lag ≤ 5s; no draft read) and freezes `Q`/`qVersion`, balances/`balanceVersion`, `fxTableVersion`, coupon snapshot, `periodState`; a missing required input fails closed here (§4.3). | +| Cascade routing (T-D-10, T-D-12) | `CascadeRouter` turns Contracts balance-effect triggers and `bandOffsetQ` shifts into delta-only `reresolve` calls, **coalesced per unit per generation** and drained on a bounded correction lane (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | Context assembly | Assembly is frozen-input gathering + a pin; no evaluation cost lives here; the hot path is the core call over the sealed tuple | Load test (slice [`16`](./16-billing-handoff-operations.md)) | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Frozen balances (§4.5) | Because balances are frozen into the context, the core stays partition-local with **zero cross-partition locks even for pooled/committed usage**; cross-unit `balanceVersion` ordering is asynchronous on the write-back (slice 15), not a hot-path lock | Design + load test | +| `cpt-cf-bss-rating-nfr-resilience` | Period-tick idempotency + cascade coalescing | The tick is idempotent per `(subscription, AnchorPeriod)`; cascade re-resolutions coalesce per unit per generation so a fan-out is bounded and drains under backpressure without blocking first rating | Chaos/retry test | + +#### Key Decisions + +| Decision | Summary | +|----------|---------| +| Freeze balances, don't lock them | C1 resolution: commitment-pool balances enter the context **frozen** at a `balanceVersion`; the core never waits on a live balance; cross-unit sequencing is Contracts' serializer + the write-back (slice 15), off the hot path (§4.5). | +| Coalesce + bound the cascade | C2 resolution: cascade triggers coalesce per `(unit, generation)` and drain on a dedicated bounded lane; fan-out is finite (structural termination — core slice [`08`](./08-retroactivity-corrections.md) §4.4) and never amplifies onto the hot path (§4.4). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-syn` + +```text +Operational stores + upstream SoRs Q store (slice 13) · pinned catalog (pricing) · Subscriptions · + │ Finance FX · Promotions coupons · Billing periodState · Contracts balances + ▼ +Unit synthesis (this slice) UnitSynthesizer · PeriodTick · ContextAssembler · CascadeRouter + │ (freeze + invoke) + ▼ +rating-core evaluate() / reresolve() over the sealed EvaluationContext + ▼ +Rated output (slice 15) outcomes + obligations + balance effects +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Unit synthesis, the period tick, context assembly + pin, cascade routing | Rust modules in the `rating` gear (pipeline crate) | +| Domain | Unit-kind shapes, `AnchorPeriod`, context-assembly + pin descriptors, cascade generation/coalescing keys | Rust; GTS + Rust domain structs | +| Infrastructure | The period-tick coordination + idempotency ledger; the cascade lane (queue) | `toolkit-db`; coordination lease library; durable lane | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Nothing reaches the core unfrozen + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-freeze-then-invoke-syn` + +Every core invocation is over a context this slice **sealed** — one `CatalogVersion`, one +`qVersion`, one `balanceVersion`, one `fxTableVersion`, one coupon snapshot, one `periodState`. A +missing input fails closed here; the core is never asked to guess or wait (core slice +[`01`](./01-foundation.md) §2.1, slice [`11`](./11-consumer-contracts.md) §2.1). + +#### The tick is idempotent + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-idempotent-tick-syn` + +A period tick for `(subscription, AnchorPeriod)` produces the same period-driven units however many +times it fires; a re-run (restart, retry, replay) never double-emits a recurring or capacity line +(§4.2). Idempotency is keyed, not timing-dependent. + +#### Cascades drain off the hot path + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-cascade-off-hotpath-syn` + +Re-resolution triggered by a correction re-enters through the core `reresolve` on a **bounded +correction lane**, coalesced per unit; it never contends with or amplifies onto first rating +(§4.4). First rating stays partition-local and cheap. + +### 2.2 Constraints + +#### Read-model pin discipline + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-pin-discipline-syn` + +Exactly one committed `CatalogVersion` per resolution run, pin-eligible only after +`CatalogVersionPublished` **and** the warm-completion marker (pin lag ≤ 5s); no draft read, no +default substitution (core slice [`11`](./11-consumer-contracts.md) §4.2; pricing design 01 §4.4). + +#### Synthesis, not aggregation or evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-synthesis-only-syn` + +This slice aggregates no usage (slice [`13`](./13-q-store-attribution.md) owns `Q`) and prices +nothing (the core does); it selects *what* to evaluate and freezes *the inputs*, then invokes the +core. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-syn` + +- **`EvaluationUnitSpec`** — the synthesized unit + its kind (`per_event` / windowed-slice / period-driven) + the identity that keys it (usage key, `(window, slice)`, or `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)`). +- **`AnchorPeriod`** — the `[periodStart, periodEnd)` UTC boundaries from the frozen `billingAnchorPolicy` (D-20 clamp); reused from core slice [`09`](./09-period-plan-change.md) `AnchorCalendar`. +- **`FrozenContext`** — the sealed `EvaluationContext` (core slice [`01`](./01-foundation.md) §3.1) with every version/pin bound: `CatalogVersion`, `(Q, qVersion)`, `(balance, balanceVersion)`, `fxTableVersion`, coupon snapshot, `periodState`. +- **`CascadeTrigger`** — a balance-effect trigger (T-D-10, from Contracts via slice [`15`](./15-rated-output-balance-effects.md)) or a `bandOffsetQ` shift (T-D-12, from slice [`13`](./13-q-store-attribution.md)); carries the affected units + the generation stamp. +- **`TickLedgerEntry`** — the idempotency record for `(subscription, AnchorPeriod)`: the tick fired, which period-driven units it emitted (§4.2). + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-unit-synthesis` + +- **`UnitSynthesizer`** — builds `EvaluationUnitSpec`s from usage arrival (slice 12/13), window close, the period tick, and cascade triggers. +- **`PeriodTick`** — the coordinated `AnchorPeriod`-boundary job; emits period-driven units idempotently per `(subscription, AnchorPeriod)` (§4.2). +- **`ContextAssembler`** — pins the `CatalogVersion` and freezes every other input into a `FrozenContext`; fails closed on any absent required input (§4.3). +- **`CascadeRouter`** — coalesces `CascadeTrigger`s per `(unit, generation)` and drains them as delta-only `reresolve` calls on the bounded correction lane (§4.4). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-synthesis-syn` + +**Inbound**: `UsageRecord`/`Q` availability (slices [`12`](./12-usage-ingestion-normalization.md)/[`13`](./13-q-store-attribution.md)); `ReMaterializationSignal` (slice 13); balance-effect cascade triggers (slice [`15`](./15-rated-output-balance-effects.md) / Contracts); the five upstream frozen inputs (slice [`11`](./11-consumer-contracts.md) §4.2–§4.6). **Outbound**: `evaluate(FrozenContext)` / `reresolve(...)` calls into `rating-core` (in-process); the resolved outcomes flow to slice [`15`](./15-rated-output-balance-effects.md). No external synchronous API. + +### 3.4 Internal Dependencies + +Upstream: slices [`12`](./12-usage-ingestion-normalization.md)/[`13`](./13-q-store-attribution.md) +(usage/`Q`), slice [`11`](./11-consumer-contracts.md) (the five input contracts). Core: invokes +[`01`](./01-foundation.md) `evaluate`/`reresolve`; reuses [`09`](./09-period-plan-change.md) +`AnchorCalendar`; routes [`08`](./08-retroactivity-corrections.md) cascades. Downstream: slice +[`15`](./15-rated-output-balance-effects.md) (persists outcomes, publishes balance effects that +feed back as cascade triggers). + +### 3.5 External Dependencies + +| Dependency | What arrives frozen | Contract | +|------------|--------------------|----------| +| Pricing (Product Catalog) | pinned read model + `CatalogVersionPublished` + warm-completion marker | slice [`11`](./11-consumer-contracts.md) §4.2 | +| Subscriptions | phase, eligibility, seat count, `(changeEffectiveAt, changeMode)`, `(currency, region)` binding | slice [`11`](./11-consumer-contracts.md) §4.3 | +| Finance / Promotions / Billing | `fxTableVersion` / coupon snapshot / `periodState` | slice [`11`](./11-consumer-contracts.md) §4.4–§4.6 | +| Contracts | commitment-pool `(balance, balanceVersion)` frozen into the context; balance-effect cascade triggers | slice [`05`](./05-commitments-reservations.md) §4.1 (T-D-10) | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-synthesize-syn` + +**Synthesize and evaluate a usage unit**: + +1. Usage/`Q` becomes available (slices [`12`](./12-usage-ingestion-normalization.md)/[`13`](./13-q-store-attribution.md)); `UnitSynthesizer` builds the `EvaluationUnitSpec` (per-event or windowed-slice, carrying `bandOffsetQ`). +2. `ContextAssembler` pins the `CatalogVersion` and freezes `(Q, qVersion)`, `(balance, balanceVersion)`, `fxTableVersion`, coupon snapshot, `periodState`; a missing required input fails closed (§4.3). +3. `rating-core` `evaluate(FrozenContext)` runs steps 1–9; the outcome flows to slice [`15`](./15-rated-output-balance-effects.md). + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-period-tick-syn` + +**Period tick**: + +1. At an `AnchorPeriod` boundary the coordinated `PeriodTick` fires for each due subscription. +2. It checks the `TickLedgerEntry` for `(subscription, AnchorPeriod)`; if already emitted, it is a no-op (idempotent). +3. Else `UnitSynthesizer` synthesizes the period-driven units (recurring lines, capacity-flavor charges, true-up surfacing — even for a zero-usage period), `ContextAssembler` freezes their contexts, and the core evaluates them; the ledger entry commits with the emitted-unit set. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-cascade-route-syn` + +**Cascade routing**: + +1. A `CascadeTrigger` arrives — a Contracts balance effect (T-D-10) or a `bandOffsetQ` shift (T-D-12) — with the affected units + a generation stamp. +2. `CascadeRouter` **coalesces** triggers for the same `(unit, generation)` into one pending re-resolution and enqueues on the bounded correction lane. +3. The lane drains: each unit `reresolve`s over its **own** pin, delta-only (core slice [`08`](./08-retroactivity-corrections.md)); the resulting deltas flow to slice [`15`](./15-rated-output-balance-effects.md). Backpressure throttles the lane; first rating is untouched (§4.4). + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-synthesis-syn` + +**Owned (partitioned by the pinned `orderingTenantId`, UTC):** + +- `period_tick_ledger` — PK `(subscription, AnchorPeriod)`; the idempotency record + emitted period-driven-unit set (§4.2). +- `cascade_lane` — the durable bounded queue of coalesced re-resolution work: `(unit, generation)` unique so a duplicate trigger folds in; status/backpressure columns (§4.4). + +`FrozenContext` itself is not persisted authoritatively — it is assembled per run from the upstream +stores; the sealed `pricingSnapshotRef` persists with the outcome (slice +[`15`](./15-rated-output-balance-effects.md)). No monetary column. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-syn` + +Pipeline crate in the one `rating` gear deployable. The `PeriodTick` runs as a **coordinated +singleton per `orderingTenantId` shard** (coordination lease library), sub-sharded by hash of +`subscriptionId` for a large tenant — the same sharding rule as slices +[`12`](./12-usage-ingestion-normalization.md)/[`13`](./13-q-store-attribution.md); the anchor-boundary +sweep for a 100K+/tenant is not serialized through one worker. The `cascade_lane` is a bounded +work queue drained by lane workers with backpressure, distinct from the first-rating path. + +## 4. Additional Context + +### 4.1 The Three Evaluation-Unit Kinds (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-unit-kinds-syn` + +- **`per_event`** — one normalized `UsageRecord` (discrete meters); the unit is the event. +- **Windowed-`Q` (per sub-window slice)** — the aggregated `Q` for `(subscription, meter, dimensionKey, window)`; when a period split partitions the window, **one unit per sub-window slice**, each carrying the frozen `bandOffsetQ` from slice [`13`](./13-q-store-attribution.md) (T-D-12) and binding exactly one pin (core slice [`01`](./01-foundation.md) §4.2). +- **Period-driven** — recurring lines, capacity-flavor charges, and period-end true-up surfacing, keyed `(subscription, priceId, chargeKind, lineKey, AnchorPeriod)` (T-D-15); synthesized by the period tick, priced by the core. A **zero-usage period still emits** its period-driven units (§4.2; PRD §12 AC 20 capacity charge). +- **Serialization across kinds (fills core slice [`01`](./01-foundation.md) §4.2)**: a usage unit serializes on its counter partition key `(subscription, meter, dimensionKey, window)`; a period-driven unit serializes on `(subscription, AnchorPeriod)` (the tick-ledger key). The two key spaces are disjoint by construction — a usage unit prices metered `Q`, a period-driven unit prices a recurring/capacity/true-up line — so they never contend for the same row; where a period-driven true-up reads a window aggregate, it reads it **frozen** (the window's `qVersion` at tick time), not the live counter. + +### 4.2 The Period Tick (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-period-tick-syn` + +- At every `AnchorPeriod` boundary (from the frozen `billingAnchorPolicy`, D-20 clamp) the tick synthesizes the period-driven units for each due subscription (T-D-15) — **no `rating-core` scheduler**; the tick lives here in the pipeline. +- **Idempotent per `(subscription, AnchorPeriod)`**: the `period_tick_ledger` records that the tick fired and which units it emitted; a re-fire (restart, retry, coordinated-lease hand-off) is a no-op. A late usage arrival into an already-ticked period does **not** re-fire the tick — it re-resolves the affected usage unit (slice [`08`](./08-retroactivity-corrections.md)); a period-driven true-up whose input aggregate changed re-resolves under its own key, it is not re-synthesized. +- **Zero-usage period**: capacity-flavor charges and recurring lines still emit (a reservation bills its allocation regardless of usage — core slice [`05`](./05-commitments-reservations.md) §4.2; PRD §12 AC 20). +- The tick reads the same frozen anchor calendar the Q store uses for window identity (slice [`13`](./13-q-store-attribution.md) §4.1), so period boundaries and tier-window boundaries never drift. + +### 4.3 Frozen-Context Assembly and Pin Discipline (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-context-assembly-syn` + +- `ContextAssembler` seals one `FrozenContext` per unit: **one committed `CatalogVersion`** (pin-eligible only after `CatalogVersionPublished` **and** the warm-completion marker; pin lag ≤ 5s; no draft read — core slice [`11`](./11-consumer-contracts.md) §4.2), plus the frozen `(Q, qVersion)` (slice [`13`](./13-q-store-attribution.md)), `(balance, balanceVersion)` (Contracts, §4.5), `fxTableVersion` (Finance), coupon snapshot (Promotions), and `periodState` (Billing). +- A missing or torn required input — no eligible pin, absent `periodState`, a torn snapshot pre-stamp/binding, an unresolvable coupon policy — **fails closed at this boundary** (core slice [`01`](./01-foundation.md) §3.3, slice [`11`](./11-consumer-contracts.md) §2.1); the core is never entered with a partial context. +- The assembled versions are exactly the determinism tuple `(window-aggregated inputs incl. bandOffsetQ, pricingSnapshotRef, fxTableVersion)` (core slice [`01`](./01-foundation.md) §4.2); re-resolution re-assembles the **same** pins (open-period) or the superseding pin (administrative re-rate — core slice [`08`](./08-retroactivity-corrections.md) §4.1). + +### 4.4 Cascade Routing, Coalescing, and Bounding (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-cascade-routing-syn` + +- Two frozen inputs couple units, so correcting one re-resolves its dependents (core slice [`08`](./08-retroactivity-corrections.md) §4.4): **(a)** a balance-affecting correction (T-D-10) — Contracts, the `balanceVersion` serializer, emits triggers for every later-`balanceVersion` unit that drew or was rated overage against the pool; **(b)** a `bandOffsetQ` shift (T-D-12) — slice [`13`](./13-q-store-attribution.md) emits triggers for the later slices of the window. +- **Coalescing**: `CascadeRouter` folds all triggers for the same `(unit, generation)` into **one** pending re-resolution (`cascade_lane` uniqueness) — a unit re-resolves **at most once per generation**, so a storm of triggers for the same unit does not multiply work. +- **Bounding + termination**: each chain is finite and strictly ordered (balance versions / slice index — core slice [`08`](./08-retroactivity-corrections.md) §4.4), so a cascade terminates; the lane is **bounded** with backpressure, so a large fan-out drains at a controlled rate and never amplifies onto the first-rating hot path. Fan-out magnitude is bounded by the number of distinct affected units, each processed once per generation. +- Every cascade `reresolve` is delta-only under its own correction key `(window[, slice], prior-rated-version, snapshot)` and its own pin; the deltas flow to slice [`15`](./15-rated-output-balance-effects.md), which dedups them (T-D-11). + +### 4.5 Commitment-Balance Freezing and the Hot Path (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-balance-freeze-syn` + +- Commitment-pool balances enter the `FrozenContext` **frozen at a `balanceVersion`** (the `commitmentReservation` segment — core slice [`01`](./01-foundation.md) §4.3, T-D-09/T-D-10). The core evaluates the step-6 waterfall over the frozen balance and **never reads or waits on a live balance** — so the hot path stays partition-local with **zero cross-partition locks, even for pooled/committed usage** (this is the C1 resolution: the scale NFR holds because the only cross-unit dependency is on the *write-back*, not the *read*). +- Cross-unit sequencing is asynchronous and off the hot path: slice [`15`](./15-rated-output-balance-effects.md) publishes each outcome's `CommitmentBalanceEffect` to Contracts (idempotent on the outcome key); **Contracts serializes** per-pool `balanceVersion` and freezes `(balance, balanceVersion)` for the next context assembly. A balance-affecting correction then cascades (§4.4) — bounded and coalesced. +- Consequence: pooled/committed usage has a **higher correction-cascade cost** than pure usage (a late correction can re-resolve later drawing units) but the **same first-rating cost and scalability** (frozen balance, no lock). The trade — cheap deterministic first rating, bounded asynchronous cascade — is the intended posture. + +## 5. Traceability + +- **PRD**: §9.2 (context inputs, Rating handoff duties), §12 AC 20 (zero-usage capacity charge), §17.1 (determinism), §6.10 (re-resolution), §7.1 (scale NFR). +- **Seams**: S1 (segments frozen into the context), M7 (`Q` frozen per `qVersion`) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-10 (balance cascade + `balanceVersion`), T-D-12 (`bandOffsetQ` cascade), T-D-15 (period tick), T-D-16 (consolidation) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). +- **Related slices**: [`01-foundation.md`](./01-foundation.md) §4.2 (unit kinds, determinism tuple), [`11-consumer-contracts.md`](./11-consumer-contracts.md) (input contracts + pin discipline), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) (cascades, `reresolve`), [`09-period-plan-change.md`](./09-period-plan-change.md) (`AnchorCalendar`), [`13-q-store-attribution.md`](./13-q-store-attribution.md) (`Q`/`bandOffsetQ` + re-materialization signals), [`15-rated-output-balance-effects.md`](./15-rated-output-balance-effects.md) (balance-effect publication feeding cascades). diff --git a/gears/bss/rating/docs/design/15-rated-output-balance-effects.md b/gears/bss/rating/docs/design/15-rated-output-balance-effects.md new file mode 100644 index 000000000..df59dc3be --- /dev/null +++ b/gears/bss/rating/docs/design/15-rated-output-balance-effects.md @@ -0,0 +1,302 @@ + + + +# DESIGN — Rated Output, Delta Dedup & Balance Effects (Slice 15, pipeline) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-design-rated-output` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Outcome → RatedCharge / BillableItem Mapping (normative, ratifies slice 11 §4.1)](#41-outcome--ratedcharge--billableitem-mapping-normative-ratifies-slice-11-41) + - [4.2 Rated-Output Store and prior-rated-version Lineage (normative)](#42-rated-output-store-and-prior-rated-version-lineage-normative) + - [4.3 Delta Dedup by Correction Key (normative)](#43-delta-dedup-by-correction-key-normative) + - [4.4 Provisional-FX Supersession (normative)](#44-provisional-fx-supersession-normative) + - [4.5 CommitmentBalanceEffect Publication (normative)](#45-commitmentbalanceeffect-publication-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **persistence and effect-publication** half of the pipeline: it stores every resolved outcome +with its sealed `pricingSnapshotRef` and a `prior-rated-version` lineage for corrections, implements +the **outcome → RatedCharge / BillableItem mapping** (core slice [`11`](./11-consumer-contracts.md) +§4.1 — this slice is the intra-gear **ratifier** ADR-0002 promised, dissolving review-finding C6), +enforces **delta dedup by the correction key** (T-D-11), materializes corrections as immutable +`Adjustment`s, and **publishes `CommitmentBalanceEffect`s** to Contracts per rated outcome (T-D-10). + +It is the boundary between the pure evaluation core and the durable financial record. Two invariants +govern it: **immutability** — a `RatedCharge` is never edited; a correction only ever *adds* an +`Adjustment` keyed by the correction key (posted-financial immutability, core slices +[`01`](./01-foundation.md)/[`08`](./08-retroactivity-corrections.md)) — and **idempotent +publication** — a retried delta returns the recorded outcome and is never re-emitted, and a +`CommitmentBalanceEffect` is idempotent on the emitting outcome's key so a redelivery never +double-draws a pool. What leaves here goes to Billing (slice +[`16`](./16-billing-handoff-operations.md)) and Contracts, never back into the core +([`../PRD.md`](../PRD.md) §6.10, §9.2). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| Outcome → RatedCharge/BillableItem mapping (core slice [`11`](./11-consumer-contracts.md) §4.1, ADR-0002 C6) | `OutcomeMapper` maps each resolved line to a `RatedCharge` (full-precision amount + rounding-policy id + sealed ref + `{skuId, planId, priceId}`); obligations ride as envelope, never charges; this slice **ratifies** the §4.1 table (§4.1). | +| `cpt-cf-bss-rating-fr-idempotency` (delta family, T-D-11) | `DeltaDedupIndex` keys every delta by `(window[, slice], prior-rated-version, snapshot)`; a retried key returns the recorded `Adjustment` and re-emits nothing (§4.3). | +| Posted-period protection (PRD §6.10) | The original `RatedCharge` is immutable; a correction materializes a new `Adjustment`; the `prior-rated-version` chain is the correction lineage (§4.2). | +| `CommitmentBalanceEffect` publication (T-D-10) | `BalanceEffectPublisher` publishes per-pool signed draw/refill deltas to Contracts via a transactional outbox, idempotent on the outcome key; Contracts serializes `balanceVersion` and cascades corrections (§4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-resilience` | Dedup + idempotent outbox | A retried outcome/delta is absorbed by the dedup index; balance effects are outbox-emitted at-least-once, idempotent on the outcome key; a crash resumes without double-charge or double-draw | Chaos/retry test | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partitioned store | The rated-output store and dedup index are partitioned by the pinned `orderingTenantId`; persistence stays partition-local; balance-effect publication is an async outbox, not a hot-path cross-partition write | Design + load test | +| `cpt-cf-bss-rating-nfr-throughput-latency` | Write path | Persistence + mapping is a single partition-local write per outcome; the balance-effect outbox drains asynchronously | Load test (slice [`16`](./16-billing-handoff-operations.md)) | + +#### Key Decisions + +| Decision | Summary | +|----------|---------| +| This slice ratifies slice 11 §4.1 | ADR-0002 makes the outcome-mapping table the actual pipeline design here (intra-gear), not a proposal awaiting an external Rating PRD; §4.1 reproduces + confirms it. | +| Rated-output dedup is the second dedup layer | Distinct from ingestion dedup (slice [`12`](./12-usage-ingestion-normalization.md)): `RatedCharge` dedups on the usage key + snapshot, `Adjustment` dedups on the correction key (T-D-11). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-rob` + +```text +rating-core outcomes (via slice 14) ResolvedPriceOutcome + obligations + sealed pricingSnapshotRef + ▼ +Rated output (this slice) OutcomeMapper · RatedOutputStore · DeltaDedupIndex · + │ AdjustmentMaterializer · BalanceEffectPublisher + ▼ +Downstream Billing handoff (slice 16) · CommitmentBalanceEffect → Contracts +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Outcome mapping, rated-output persistence, delta dedup, adjustment materialization, balance-effect publication | Rust modules in the `rating` gear (pipeline crate) | +| Domain | `RatedCharge`/`BillableItem`/`Adjustment` shapes, `prior-rated-version` chain, `CommitmentBalanceEffect` | Rust; GTS + Rust domain structs | +| Infrastructure | The **rated-output store**, the **delta-dedup index**, the balance-effect **transactional outbox** | PostgreSQL, SecureORM (`toolkit-db`) | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Immutable charges, additive corrections + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-immutable-charges-rob` + +A `RatedCharge` is never edited; a correction is a new `Adjustment` keyed by the correction key, and +the `prior-rated-version` chain records the lineage (posted-financial immutability — core slices +[`01`](./01-foundation.md)/[`08`](./08-retroactivity-corrections.md); PRD §6.10). + +#### Idempotent everywhere it publishes + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-idempotent-publish-rob` + +Delta dedup returns the recorded outcome on a retried correction key; balance-effect publication is +idempotent on the emitting outcome key; the Billing handoff (slice +[`16`](./16-billing-handoff-operations.md)) is idempotent on the usage/correction key. No retry +double-charges or double-draws (T-D-11, T-D-10). + +#### Persist what the core sealed, add nothing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-persist-sealed-rob` + +The `pricingSnapshotRef` is sealed by the core; this slice persists it verbatim and never mints or +edits a segment. Rated output is a faithful record of a core outcome, not a second evaluation. + +### 2.2 Constraints + +#### Delta-dedup owner: Rating (here) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-delta-dedup-here-rob` + +The delta-dedup **owner is Rating**, enforced in this slice — the delta persister (T-D-11; core +slice [`01`](./01-foundation.md) §2.2, slice [`08`](./08-retroactivity-corrections.md) §2.2); +Billing's idempotent `Adjustment` consumption is defense in depth. + +#### No money computed, only recorded + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-record-not-compute-rob` + +This slice computes no amount — it records the core's full-precision outcome and the rounding-policy +id; rounding and floor/cap execution are Billing's (core slice [`01`](./01-foundation.md) §4.4, slice +[`09`](./09-period-plan-change.md)). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-rob` + +- **`RatedCharge`** — one charge line + unit: full-precision amount, rounding-policy id, sealed `pricingSnapshotRef`, `{skuId, planId, priceId}`, discount/FX lineage, ASC refs (null@MVP), the provisional flag (invoice-period FX), the usage/period key; immutable. +- **`Adjustment`** — a correction delta: correction key `(window[, slice], prior-rated-version, snapshot)`, signed full-precision amount, reversal effects, bitemporal stamps; immutable; references the `RatedCharge` version it corrects. +- **`RatedOutputVersion`** — the `prior-rated-version` chain node: which version of a window/unit's rating this outcome is, for deterministic diffing (core slice [`08`](./08-retroactivity-corrections.md)). +- **`CommitmentBalanceEffect`** — per-pool signed draw/refill deltas for one outcome, idempotent on the outcome key; published to Contracts (T-D-10). +- **`BillableItem`** — the Billing-facing envelope carrying the `RatedCharge`/`Adjustment` + obligations for slice [`16`](./16-billing-handoff-operations.md). + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-rated-output` + +- **`OutcomeMapper`** — maps a `ResolvedPriceOutcome` to `RatedCharge`(s) / `Adjustment`(s) per the §4.1 table; obligations become envelope ride-alongs, never charges. +- **`RatedOutputStore`** — persists outcomes + sealed ref + the `prior-rated-version` chain (§4.2). +- **`DeltaDedupIndex`** — the authoritative delta dedup on the correction key (§4.3). +- **`AdjustmentMaterializer`** — turns a core `reresolve` delta into an immutable `Adjustment`. +- **`BalanceEffectPublisher`** — publishes `CommitmentBalanceEffect`s to Contracts via the transactional outbox, idempotent on the outcome key (§4.5). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-rated-output-rob` + +**Inbound (from slice [`14`](./14-unit-synthesis-period-tick.md) / `rating-core`)**: resolved +outcomes (first rating) and `reresolve` deltas, with the sealed `pricingSnapshotRef` and obligations. +**Outbound**: `RatedCharge`/`Adjustment` + obligations to slice +[`16`](./16-billing-handoff-operations.md) (Billing handoff); `CommitmentBalanceEffect`s to Contracts +(outbox). The `CommitmentBalanceEffect` event schema is a **Contracts/Rating cross-PRD obligation** +to mirror (T-D-10) — the Rating side is specified here. + +### 3.4 Internal Dependencies + +Upstream: slice [`14`](./14-unit-synthesis-period-tick.md) (delivers core outcomes + routes cascade +`reresolve`), core slices [`01`](./01-foundation.md) (outcome envelope, sealed ref), +[`05`](./05-commitments-reservations.md) §4.1 (balance-effect contract), +[`08`](./08-retroactivity-corrections.md) (correction keys, delta shape). Downstream: slice +[`16`](./16-billing-handoff-operations.md) (Billing delivery). Ratifies the mapping of slice +[`11`](./11-consumer-contracts.md) §4.1. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Contracts | `CommitmentBalanceEffect`s out (per-pool signed deltas); balance-effect cascade triggers back in (via slice 14) | slice [`05`](./05-commitments-reservations.md) §4.1 (T-D-10); cross-PRD event schema | +| Billing (via slice 16) | `RatedCharge`/`Adjustment` + obligations out at full precision | slice [`11`](./11-consumer-contracts.md) §4.6; slice [`16`](./16-billing-handoff-operations.md) | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-persist-outcome-rob` + +**Persist a first-rating outcome**: + +1. A resolved outcome arrives from slice [`14`](./14-unit-synthesis-period-tick.md) with its sealed ref + obligations. +2. `OutcomeMapper` produces the `RatedCharge`(s) per §4.1 (incl. zero-due `prepaid_drawdown` lines, provisional invoice-period-FX flag); obligations become envelope ride-alongs. +3. `RatedOutputStore` persists the outcome + sealed ref + `RatedOutputVersion` (partition-local write); the `RatedCharge` dedups on the usage/period key + snapshot. +4. `BalanceEffectPublisher` enqueues the `CommitmentBalanceEffect` on the transactional outbox (same commit as the store write); slice [`16`](./16-billing-handoff-operations.md) delivers the `BillableItem` to Billing. + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-persist-delta-rob` + +**Persist a correction delta**: + +1. A `reresolve` delta arrives (core slice [`08`](./08-retroactivity-corrections.md)) with its correction key `(window[, slice], prior-rated-version, snapshot)`. +2. `DeltaDedupIndex` checks the key: a known key returns the recorded `Adjustment` and stops (idempotent replay, no re-emit). +3. Else `AdjustmentMaterializer` writes a new immutable `Adjustment` (signed amount, reversal effects, bitemporal stamps) and advances the `prior-rated-version` chain; the original `RatedCharge` is untouched. +4. The `Adjustment` + any per-pool `CommitmentBalanceEffect` (refill/re-draw) publish downstream idempotently on the correction key. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-rated-output-rob` + +**Owned (partitioned by the pinned `orderingTenantId`, UTC):** + +- `rated_output` — resolved outcome + sealed `pricingSnapshotRef` + `RatedOutputVersion` chain; the `RatedCharge` rows (immutable); dedup index on `(usageKey | period-unit key, snapshot)`. +- `delta_dedup` — unique index on the correction key `(window[, slice], prior-rated-version, snapshot)`; the authoritative delta dedup. +- `adjustment` — immutable correction deltas (append-only). +- `balance_effect_outbox` — the transactional outbox for `CommitmentBalanceEffect`s (committed with `rated_output`; at-least-once; idempotent on the outcome key). + +Concrete DDL is Design. `RATED`/`Adjustment` rows carry full-precision amounts + the rounding-policy id +(never rounded here); no floor/cap or period min/max is applied — that is Billing's (core slice +[`09`](./09-period-plan-change.md)). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-rob` + +Pipeline crate in the one `rating` gear deployable. Persistence is partition-local on the pinned +`orderingTenantId`; the `balance_effect_outbox` drains via a coordinated relay (coordination lease +library, per-shard) — asynchronous, so the balance-effect cross-gear write never blocks the +partition-local rated-output write. First rating and the correction lane (slice +[`14`](./14-unit-synthesis-period-tick.md)) both land here; dedup keeps re-resolutions idempotent. + +## 4. Additional Context + +### 4.1 Outcome → RatedCharge / BillableItem Mapping (normative, ratifies slice 11 §4.1) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-outcome-mapping-rob` + +This slice **ratifies** the core slice [`11`](./11-consumer-contracts.md) §4.1 mapping (ADR-0002 +dissolves the external-ratifier open — it is now intra-gear): + +| Outcome element | Mapping (here) | +|---|---| +| resolved line (usage / recurring / `capacityCharge`) | one `RatedCharge` per charge line + unit: full-precision amount + rounding-policy id; `BillableItem.pricingSnapshotRef` = the sealed ref; `{skuId, planId, priceId}` verbatim | +| zero-due `prepaid_drawdown` in-commit line (core slice [`05`](./05-commitments-reservations.md) §4.1, T-D-14) | a `RatedCharge` with amount-due 0 + notional value in lineage — **itemized, never dropped** | +| obligations (`TrueUpObligation`, `PeriodFloorCapObligation`) | **envelope ride-along** to Billing execution — never a `RatedCharge` | +| provisional invoice-period-FX amount (core slice [`07`](./07-currency-fx.md)) | `RatedCharge` flagged **provisional**; the close-time delta supersedes by correction key, never mutates (§4.4) | +| correction deltas (core slice [`08`](./08-retroactivity-corrections.md)) | immutable `Adjustment` keyed by the correction key; the original `RatedCharge` is immutable | +| idempotency | usage/period key ⇒ `RatedCharge` dedup; correction key ⇒ `Adjustment` dedup — both enforced **here** (T-D-11) | +| `CommitmentBalanceEffect` (core slice [`05`](./05-commitments-reservations.md) §4.1, T-D-10) | published to Contracts per rated outcome, idempotent on the outcome key (§4.5) | + +Incompatible changes to this mapping take a **major version bump** (PRD §9.1); the mapping is now +stable design, not a proposal. + +### 4.2 Rated-Output Store and prior-rated-version Lineage (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-rated-store-rob` + +- Every outcome persists with its **sealed `pricingSnapshotRef`** and a `RatedOutputVersion` — the `prior-rated-version` the core diffs a correction against (core slice [`08`](./08-retroactivity-corrections.md) §4.2). The store is the authoritative rated record; loss of any downstream cache is repaired by replay from it + the pinned snapshot. +- A `RatedCharge` is **immutable**: a correction never edits it; it materializes a new `Adjustment` and advances the version chain. The chain of `(RatedOutputVersion, correction key)` is a window/unit's full rating lineage. +- Snapshots referenced by rated output MUST remain resolvable for the full correction horizon (posted-period corrections arrive years later — the ≥ 7-year retention of core slice [`08`](./08-retroactivity-corrections.md) §4.1); this slice retains the sealed ref, pricing retains the snapshot content. + +### 4.3 Delta Dedup by Correction Key (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-delta-dedup-rob` + +- The **delta-dedup owner is Rating**, enforced here as the delta persister (T-D-11; core slice [`01`](./01-foundation.md) §2.2, slice [`08`](./08-retroactivity-corrections.md) §2.2/§4.2). `DeltaDedupIndex` is a unique index on `(window[, slice], prior-rated-version, snapshot)`. +- A retried correction key **returns the recorded `Adjustment`** and re-emits nothing downstream; a genuinely new correction to the same window diffs against the then-current `prior-rated-version`, producing a **new** key (the keys never repeat, and the chain is the correction lineage). +- Billing additionally treats `Adjustment` consumption as idempotent on the same key — **defense in depth**, not the primary guard (core slice [`08`](./08-retroactivity-corrections.md) §2.2). + +### 4.4 Provisional-FX Supersession (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-provisional-fx-rob` + +- An invoice-period-FX line is persisted as a **provisional** `RatedCharge` (core slice [`07`](./07-currency-fx.md) §4.2); at period close the close-time re-rate arrives as a `reresolve` delta (a full re-execution of step 8 + the billing-currency coupon pass — core slice [`07`](./07-currency-fx.md) §3.6) under the correction key. +- The delta **supersedes** the provisional amount via a new `Adjustment` — the provisional `RatedCharge` is **never mutated**; Billing sees the provisional line then its close-time adjustment, both immutable, both keyed. The provisional flag on the original + the correction key on the delta let Billing reconcile without re-computation. + +### 4.5 CommitmentBalanceEffect Publication (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-balance-effect-rob` + +- Per rated outcome that drew or refilled a commitment pool, this slice publishes a **`CommitmentBalanceEffect`** (per-pool signed draw/refill deltas) to Contracts via the `balance_effect_outbox`, **idempotent on the emitting outcome's evaluation/correction key** (T-D-10; core slice [`05`](./05-commitments-reservations.md) §4.1). A redelivery never double-draws. +- **Contracts serializes** per-pool `balanceVersion`, applies effects in received order, and freezes `(balance, balanceVersion)` for the next context assembly (slice [`14`](./14-unit-synthesis-period-tick.md) §4.5) — so the cross-unit ordering the core depends on is Contracts', **off the rating hot path**; rating's obligation ends at publishing the per-pool effect. +- A balance-affecting correction cascades: Contracts emits re-resolution triggers for every later-`balanceVersion` unit that drew or was rated overage against the pool; slice [`14`](./14-unit-synthesis-period-tick.md) routes them, coalesced and bounded (§4.4 there), and the resulting deltas land back here — deduped (§4.3), each under its own pin. +- The `CommitmentBalanceEffect` **event schema** is a Contracts/Rating cross-PRD obligation to mirror (T-D-10); the Rating-side shape (per-pool signed deltas, outcome-key idempotency, draw order) is fixed here — the Contracts-side application/serialization is theirs. + +## 5. Traceability + +- **PRD**: §9.2 (Rating handoff), §6.1 (`fr-idempotency` delta family), §6.10 (delta-only corrections, posted immutability), §7.1 (scale/resilience NFRs). +- **Seams**: M7 (rated aggregate lineage), S1 (sealed ref persisted), B1 (bundle component lineage addressable in rated output) — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: T-D-10 (balance effects + cascade), T-D-11 (delta dedup owner = Rating, here), T-D-14 (zero-due prepaid line), T-D-16 (consolidation; ratifies slice 11 §4.1) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). +- **Related slices**: [`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.1 (the mapping this slice ratifies), [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) §4.2 (correction keys), [`05-commitments-reservations.md`](./05-commitments-reservations.md) §4.1 (balance-effect contract), [`07-currency-fx.md`](./07-currency-fx.md) (provisional FX), [`14-unit-synthesis-period-tick.md`](./14-unit-synthesis-period-tick.md) (outcomes in, cascades), [`16-billing-handoff-operations.md`](./16-billing-handoff-operations.md) (Billing delivery). diff --git a/gears/bss/rating/docs/design/16-billing-handoff-operations.md b/gears/bss/rating/docs/design/16-billing-handoff-operations.md new file mode 100644 index 000000000..050481ef8 --- /dev/null +++ b/gears/bss/rating/docs/design/16-billing-handoff-operations.md @@ -0,0 +1,279 @@ + + + +# DESIGN — Billing Handoff & Operations (Slice 16, pipeline) + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-design-billing-handoff` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Billing Delivery Contract (normative)](#41-billing-delivery-contract-normative) + - [4.2 periodState Relay (normative)](#42-periodstate-relay-normative) + - [4.3 Operational Topology and Lanes (normative)](#43-operational-topology-and-lanes-normative) + - [4.4 Backpressure, Replay, and Cold Start (normative)](#44-backpressure-replay-and-cold-start-normative) + - [4.5 NFR Verification (normative)](#45-nfr-verification-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **outbound edge** of the gear: it delivers rated charges, `Adjustment`s, and the obligations +envelope (`TrueUpObligation`, `PeriodFloorCapObligation`) to **Billing** at full intermediate +precision with the rounding-policy id — Billing aggregates the period, executes floor/cap, and +applies all rounding. It relays the inbound `periodState` (`open` / `closed_posted`) into context +assembly (slice [`14`](./14-unit-synthesis-period-tick.md)), and it owns the **operational topology** +of the whole pipeline deployable: partitioning on the M7 key, the hot / correction / balance-effect +**lanes**, backpressure, cold-start of the non-authoritative caches, and the **NFR verification home** +(p95 latency, ≥ 10M events/day/region — PRD §7.1). Consolidation (ADR-0002) makes the previously +undocumented Rating → Billing contract a first-class gear boundary here. + +The two invariants it carries outward are the ones the core established and the pipeline preserved: +**full precision out, execution downstream** (the gear never rounds and never applies period-level +min/max — Billing does) and **idempotent delivery** (Billing consumes on the usage/correction key, so +a redelivery never double-posts). What crosses this edge is the money-shaped record; what stays is +every operational concern of running the gear at scale ([`../PRD.md`](../PRD.md) §6.11, §9.2, §7.1). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-rating-contract-billing-periodstate` (core slice [`11`](./11-consumer-contracts.md) §4.6) | `BillingDelivery` emits `BillableItem`s (RatedCharge/Adjustment) + obligations at full precision + rounding-policy id; `PeriodStateRelay` feeds the inbound `periodState` into slice [`14`](./14-unit-synthesis-period-tick.md); a missing `periodState` fails closed upstream (§4.1, §4.2). | +| Floor/cap execution split (PRD §6.11) | The gear surfaces `PeriodFloorCapObligation`; **Billing executes** `max/min` at period aggregation and rounds; the non-negative guard already ran per line (core slice [`01`](./01-foundation.md) §4.4) — a floor never masks a negative line (§4.1). | +| Delta-only posted corrections (PRD §6.10) | Correction traffic reaches Billing **only** as `Adjustment`s; posted invoice lines are immutable; delivery is idempotent on the correction key (§4.1). | +| NFR show-stoppers (PRD §7.1) | This slice is the verification home: p95 lookup ≤ 100ms / path < 1s, ≥ 10M ev/day/region, deterministic replay — exercised against the pipeline end-to-end (§4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-rating-nfr-throughput-latency` | Lanes + partitioning | The hot lane (first rating) is partition-local and pure; correction and balance-effect lanes are bounded and off the hot path; delivery is an async outbox | **NFR workshop targets provisional** ([`../PRD.md`](../PRD.md) §7.1); load test here | +| `cpt-cf-bss-rating-nfr-horizontal-scale` | Partition contract | Partition by the pinned `orderingTenantId`, sub-sharded by hash of `subscriptionId`; per-counter M7 key ordering; zero cross-partition locks on the hot path | Load test at ≥ 10M ev/day/region | +| `cpt-cf-bss-rating-nfr-resilience` | Idempotent delivery + replay | At-least-once outbox, idempotent Billing consumption on the usage/correction key; deterministic replay from the authoritative stores + pinned snapshots | Chaos/retry + replay test | + +#### Key Decisions + +| Decision | Summary | +|----------|---------| +| Outbox delivery to Billing | The Rating → Billing handoff is a transactional-outbox event stream (at-least-once, idempotency-keyed, ordered per the M7 partition), mirroring the pricing/subscriptions outbox pattern — not a synchronous call. | +| Three lanes, one deployable | Hot (first rating), correction (cascade `reresolve`), and balance-effect publication run as distinct bounded lanes in the one `rating` gear deployable so corrections never starve or block first rating. | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-tech-stack-bhf` + +```text +Rated output (slice 15) RatedCharge / Adjustment + obligations + sealed ref + ▼ +Billing handoff (this slice) BillingDelivery (outbox) · PeriodStateRelay · LaneManager · + │ BackpressureController · ColdStartLoader + ▼ +Billing (external) aggregates the period · executes floor/cap · rounds · posts +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Billing delivery, `periodState` relay, lane/backpressure management, cold start, NFR harness | Rust modules in the `rating` gear (pipeline crate) | +| Domain | `BillableItem` delivery envelope, `periodState`, lane descriptors | Rust; GTS + Rust domain structs | +| Infrastructure | The Billing **delivery outbox**; the non-authoritative caches' cold-start loader; the durable lanes | PostgreSQL, SecureORM (`toolkit-db`); durable transport; coordination lease library | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Full precision out, execution downstream + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-precision-out-bhf` + +Amounts cross to Billing at full intermediate precision with the rounding-policy id; the gear never +rounds and never applies period-level min/max — Billing aggregates, executes floor/cap, and rounds +(core slices [`01`](./01-foundation.md) §4.4 / [`09`](./09-period-plan-change.md) §2.1). + +#### Idempotent delivery + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-idempotent-delivery-bhf` + +Delivery is at-least-once and idempotent on the usage/correction key; a redelivery never double-posts +(the dedup guarantee of slice [`15`](./15-rated-output-balance-effects.md) extended to the wire — +Billing consumes idempotently as defense in depth). + +#### Corrections never block first rating + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-principle-lanes-bhf` + +First rating, cascade re-resolution, and balance-effect publication run on **distinct bounded lanes**; +a correction storm drains under backpressure without starving or blocking the hot path (§4.3). + +### 2.2 Constraints + +#### The gear rounds nothing, executes no floor/cap + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-no-round-bhf` + +rating-core-era invariant preserved (core slice [`09`](./09-period-plan-change.md) §2.1): obligations are +surfaced, Billing executes; the emission records the rounding-policy id, and the non-negative guard +has already run per line. + +#### periodState is required, sourced only from Billing + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-constraint-periodstate-billing-bhf` + +`periodState` is Billing's alone; the relay carries it into context assembly, and a missing value +fails closed upstream (core slice [`08`](./08-retroactivity-corrections.md) §4.3, slice +[`14`](./14-unit-synthesis-period-tick.md) §4.3) — never guessed here. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-domain-model-bhf` + +- **`BillableItemDelivery`** — the outbound envelope: `RatedCharge` / `Adjustment` + obligations + sealed `pricingSnapshotRef` + `{skuId, planId, priceId}` + rounding-policy id + the delivery idempotency key (usage/correction key). +- **`PeriodStateInput`** — the inbound `periodState ∈ {open, closed_posted}` for a subscription/period, relayed to slice [`14`](./14-unit-synthesis-period-tick.md). +- **`Lane`** — a bounded work lane descriptor (hot / correction / balance-effect) with its concurrency + backpressure policy. +- **`DeliveryOutboxEntry`** — the transactional outbox row for one Billing delivery; at-least-once, idempotency-keyed. + +### 3.2 Component Model + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-component-billing-handoff` + +- **`BillingDelivery`** — writes `BillableItemDelivery` to the transactional outbox (same commit as rated output) and relays to Billing at-least-once, ordered per M7 partition, idempotency-keyed. +- **`PeriodStateRelay`** — ingests Billing's `periodState` and makes it available to context assembly (slice [`14`](./14-unit-synthesis-period-tick.md)); fail-closed absence. +- **`LaneManager`** — runs the hot / correction / balance-effect lanes with distinct bounded concurrency (§4.3). +- **`BackpressureController`** — throttles the correction/balance-effect lanes under load so the hot lane keeps its latency budget (§4.4). +- **`ColdStartLoader`** — warms the non-authoritative caches (resolved-window cache — core slice [`01`](./01-foundation.md) §3.7; FX-table pages) after a restart; correctness never depends on a warm cache (§4.4). + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-interface-billing-handoff-bhf` + +**Outbound (to Billing)**: the `BillableItemDelivery` event stream — transport, ordering (per M7 +partition), and idempotent consumption on the usage/correction key; full-precision amounts + +rounding-policy id + obligations. **Inbound (from Billing)**: `periodState`. The Billing-facing +contract shape is co-owned with Billing (core slice [`11`](./11-consumer-contracts.md) §4.6); this +slice fixes the Rating-side delivery semantics + the operational transport. + +### 3.4 Internal Dependencies + +Upstream: slice [`15`](./15-rated-output-balance-effects.md) (the rated output + obligations +delivered). Feeds context assembly (slice [`14`](./14-unit-synthesis-period-tick.md)) with +`periodState`. Preserves the obligation/precision invariants of core slices +[`01`](./01-foundation.md)/[`09`](./09-period-plan-change.md). Boundary contract: slice +[`11`](./11-consumer-contracts.md) §4.6. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Billing | `BillableItem`/`Adjustment` + obligations out (full precision); `periodState` in; Billing aggregates, executes floor/cap, rounds, posts | core slice [`11`](./11-consumer-contracts.md) §4.6; PRD §6.11, §9.2 | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-flow-deliver-bhf` + +**Deliver to Billing**: + +1. Slice [`15`](./15-rated-output-balance-effects.md) commits a `RatedCharge`/`Adjustment` + obligations; `BillingDelivery` writes the `DeliveryOutboxEntry` in the **same commit** (no rated output without its delivery queued, no delivery without a committed outcome). +2. The outbox relays to Billing at-least-once, ordered per the M7 partition, keyed by the usage/correction key. +3. Billing consumes idempotently, aggregates the period, executes `max(total, floor)`/`min(total, cap)` from the obligation, and rounds; a redelivery is a no-op on Billing's side. +4. Corrections arrive as `Adjustment`s only; posted invoice lines stay immutable. + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-flow-periodstate-relay-bhf` + +**periodState relay**: Billing publishes `periodState` transitions; `PeriodStateRelay` makes the +current value available to slice [`14`](./14-unit-synthesis-period-tick.md) context assembly, which +freezes it into the `EvaluationContext`; a missing value fails closed at assembly (never guessed). + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-rating-storage-billing-handoff-bhf` + +**Owned (partitioned by the pinned `orderingTenantId`, UTC):** + +- `billing_delivery_outbox` — the transactional outbox for `BillableItemDelivery` (committed with slice 15's `rated_output`); at-least-once, idempotency-keyed on the usage/correction key, ordered per M7 partition. +- `period_state` — the relayed Billing `periodState` per subscription/period (a projection, non-authoritative — Billing is the SoR; refreshed on Billing events). + +Concrete DDL is Design. No monetary computation, no floor/cap, no rounding here. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-rating-deployment-bhf` + +The one `rating` gear deployable, partitioned by the pinned `orderingTenantId` and sub-sharded by +hash of `subscriptionId`. **Three lanes** run per shard (coordination lease library): the **hot lane** +(ingestion → Q → synthesis → core → rated output → delivery, partition-local, latency-budgeted); the +**correction lane** (cascade `reresolve`, bounded, backpressured — slice [`14`](./14-unit-synthesis-period-tick.md) §4.4); and the **balance-effect lane** (the outbox relay to Contracts — slice +[`15`](./15-rated-output-balance-effects.md) §4.5). `rating-core` remains a pure I/O-free crate the +hot and correction lanes call (ADR-0002); the non-authoritative caches are per-instance and +cold-startable (§4.4). + +## 4. Additional Context + +### 4.1 Billing Delivery Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-billing-delivery-bhf` + +- Delivery carries `RatedCharge`/`Adjustment` + obligations (`TrueUpObligation`, `PeriodFloorCapObligation`) + the sealed `pricingSnapshotRef` + `{skuId, planId, priceId}` + the rounding-policy id, at **full intermediate precision** (the concrete DECIMAL precision is fixed with Billing before Design lock — core slice [`01`](./01-foundation.md) §1.2, still open). +- **Billing executes and rounds**: `max(total, floor)`/`min(total, cap)` at period aggregation, then the rounding policy; the gear applies neither (core slice [`09`](./09-period-plan-change.md) §2.1). The per-line non-negative guard has already run (core slice [`01`](./01-foundation.md) §4.4) — a floor never masks a negative line. +- **Corrections deliver only as `Adjustment`s** on the correction key; posted invoice lines are immutable (PRD §6.10). Delivery is **idempotent** on the usage/correction key: at-least-once transport + idempotent Billing consumption (defense in depth over slice [`15`](./15-rated-output-balance-effects.md)'s dedup). +- Ordering: deliveries are ordered per the M7 partition `(subscription, meter, dimensionKey, window)` and, for period-driven units, per `(subscription, AnchorPeriod)` — the same partition discipline the whole gear rides (SEAMS M7; shared ordering key with subscriptions, SUB-R1). + +### 4.2 periodState Relay (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-periodstate-relay-bhf` + +- `periodState ∈ {open, closed_posted}` is **Billing's alone**; the relay carries the current value into slice [`14`](./14-unit-synthesis-period-tick.md) context assembly, which freezes it into the `EvaluationContext`. `open` routes late/corrected usage through pinned-snapshot re-resolution; `closed_posted` engages posted-period delta-only protection (core slice [`08`](./08-retroactivity-corrections.md) §4.3). +- **Missing `periodState` fails closed** at assembly (core slice [`14`](./14-unit-synthesis-period-tick.md) §4.3) — never guessed here; the relay's `period_state` projection is non-authoritative (Billing is the SoR) and is refreshed on Billing events. + +### 4.3 Operational Topology and Lanes (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-lanes-bhf` + +- The gear runs as one deployable, partitioned by the pinned `orderingTenantId`, sub-sharded by hash of `subscriptionId` (the same rule as the subscriptions gear jobs), so no single worker funnels a large tenant's ingestion, period tick, or delivery. +- **Three bounded lanes** per shard: the **hot lane** (first rating, partition-local, pure core call, latency-budgeted); the **correction lane** (cascade `reresolve` — coalesced and bounded, slice [`14`](./14-unit-synthesis-period-tick.md) §4.4); the **balance-effect lane** (outbox relay to Contracts, slice [`15`](./15-rated-output-balance-effects.md) §4.5). A correction/cascade storm drains on its own lane under backpressure and **never blocks or starves first rating** (the C2 fan-out bound realized operationally). +- Single-writer per M7 counter key is realized by partition ownership (slice [`13`](./13-q-store-attribution.md) §3.8); zero cross-partition locks on the hot path — including for pooled/committed usage, because balances are frozen into the context (slice [`14`](./14-unit-synthesis-period-tick.md) §4.5, the C1 resolution). + +### 4.4 Backpressure, Replay, and Cold Start (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-backpressure-replay-bhf` + +- **Backpressure** is transport-native: an unacked ingestion offset (slice [`12`](./12-usage-ingestion-normalization.md)) or a full correction lane throttles intake rather than dropping work; a Billing-delivery outbox that cannot drain applies backpressure to its lane, never a silent drop. +- **Replay** is deterministic end-to-end: the authoritative stores (`usage_record`/`usage_dedup` s12, `windowed_counter` s13, `rated_output`/`delta_dedup` s15) plus the pinned snapshots reproduce any outcome byte-identically (core slice [`01`](./01-foundation.md) §4.2); a lost non-authoritative cache or an in-flight lane is rebuilt by replay, never by a guess. +- **Cold start**: after a restart, `ColdStartLoader` warms the resolved-window cache (core slice [`01`](./01-foundation.md) §3.7) and FX-table pages; correctness never depends on a warm cache — a cold read degrades latency only. Delivery/outbox relays resume from their durable offsets; the dedup indexes make resumed work idempotent. + +### 4.5 NFR Verification (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-rating-normative-nfr-verification-bhf` + +- This slice is the **NFR verification home** for the gear (PRD §7.1): p95 catalog-lookup ≤ 100ms and per-line path < 1s (the lookup cost is the read-model pin, not evaluation — core slice [`01`](./01-foundation.md) §1.2); throughput **≥ 10M events/day/region** on the ingestion → delivery hot lane; deterministic replay and idempotent retry across all lanes. +- **Targets are provisional pending the program NFR workshop** ([`../PRD.md`](../PRD.md) §7.1); the verification plan (load at ≥ 10M ev/day/region, chaos/retry, replay-equivalence, and a correction-storm test asserting the hot lane keeps its budget under a bounded cascade) is exercised against the assembled pipeline here. +- **Scale-posture record (from the design review)**: pure/first-rating usage is partition-local with zero cross-partition locks; pooled/committed usage keeps the same first-rating scalability (frozen balance) at a higher **bounded** correction-cascade cost (slice [`14`](./14-unit-synthesis-period-tick.md) §4.4/§4.5) — the verification MUST include a pooled-usage cascade scenario, not only pure usage. + +## 5. Traceability + +- **PRD**: §9.2 (Billing contract), §6.11 (floor/cap execution split), §6.10 (delta-only corrections), §7.1 (NFR show-stoppers), §4.1 (precision). +- **Seams**: M7 (partition/ordering), B1 (bundle lineage delivered) — [`../SEAMS.md`](../SEAMS.md); shared ordering key with subscriptions (SUB-R1). +- **Decisions**: T-D-16 (consolidation — the Rating → Billing contract becomes first-class here); T-D-10/T-D-11 (balance-effect + delta lanes) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md`](../ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md). +- **Related slices**: [`09-period-plan-change.md`](./09-period-plan-change.md) (obligation shapes + no-round invariant), [`11-consumer-contracts.md`](./11-consumer-contracts.md) §4.6 (Billing contract), [`15-rated-output-balance-effects.md`](./15-rated-output-balance-effects.md) (upstream store + outbox), [`14-unit-synthesis-period-tick.md`](./14-unit-synthesis-period-tick.md) (periodState consumer, lanes), [`12-usage-ingestion-normalization.md`](./12-usage-ingestion-normalization.md)/[`13-q-store-attribution.md`](./13-q-store-attribution.md) (partitioning, backpressure). diff --git a/gears/bss/rating/docs/design/README.md b/gears/bss/rating/docs/design/README.md new file mode 100644 index 000000000..03ed50029 --- /dev/null +++ b/gears/bss/rating/docs/design/README.md @@ -0,0 +1,76 @@ + + + +# Rating — Design Set + + + +- [Slice documents](#slice-documents) +- [Slice map (PRD §6 / §17.1 step ↔ slice)](#slice-map-prd-6--171-step--slice) + + + +This folder holds the **rating gear's** technical design as a **set of slice designs** +(ADR-0002): the **evaluation core** — a shared **Evaluation Foundation** +([`01-foundation.md`](./01-foundation.md)): the pure-function core, the adopted pricing 8-axis +scope key, determinism, and `pricingSnapshotRef` composition — plus per-step / per-capability +evaluator slices decomposed along the deterministic rule order (§17.1 steps 1-9), and the +**operational pipeline** (slices 12–16: ingestion, the windowed `Q` store, unit synthesis and the +period tick, rated-output persistence, the Billing handoff). Every evaluation slice runs +**through** the Foundation core over a **frozen** snapshot; none re-queries mutable catalog state +on the hot path. + +The gear is not an authoring System of Record: the pricing gear owns the catalog (scope key, +`PriceWindow`, `PriceOverlay`, `CatalogVersion`, publish governance) — tariffs in the rate-card +sense live there. The cross-gear contract is frozen in [`../SEAMS.md`](../SEAMS.md); this design +implements the rating side of every resolved seam (terminology bridge for historical +"Rating"/"Rating" wording: [`../DESIGN.md`](../DESIGN.md)). + +**The canonical index — architecture overview, slice map, dependency order, cross-cutting +normatives, the ADR index, and traceability — is [`../DESIGN.md`](../DESIGN.md).** Requirements +(WHAT/WHY) live in [`../PRD.md`](../PRD.md); decision rationale in [`../ADR/`](../ADR/) and the +cross-gear seam register in [`../SEAMS.md`](../SEAMS.md). + +## Slice documents + +- [`01-foundation.md`](./01-foundation.md) — **Evaluation Foundation** — The shared pure-function evaluation core that every step slice runs *through*: the evaluation context, the **adopted** pricing 8-axis canonical scope key, byte-for-byte determinism over frozen inputs, `pricingSnapshotRef` composition (Rating = composition SoR), the single-outcome / idempotency / non-negative guards, and rating-core-crate deployment (ADR-0002). (PRD §6.1, §17.1 core) +- [`02-selection-eligibility.md`](./02-selection-eligibility.md) — **Base Selection & Eligibility** — Steps 1-2: resolve the active plan phase (`phase_id`) and select the single `Price`/`PriceWindow` on the 8-axis key, applying the `priceEligibility` class order and **multi-generation `cohort` selection** by the subscription's pinned price id, with no silent fallback and the phase-invariant usage fallback. (PRD §6.3 (steps 1-2), §6.5) +- [`03-metering-models.md`](./03-metering-models.md) — **Metering & Pricing Models** — Step 3 and the model formulas: meter mapping + billing granularity; the `modelKind` set `{flat, per_unit, graduated, volume, package}` plus hybrid composition; the tier aggregation window over the `(subscription, meter, dimensionKey, window)` counter; dimensional `(meter, dimensionKey)` lines; and composite / derived-meter evaluation. (PRD §6.2, §6.5, §6.7) +- [`04-overlays-precedence.md`](./04-overlays-precedence.md) — **Overlays & Precedence** — Steps 4-5: PriceOverlay scope resolution and the **stacking** model (all survivors applied; the class-specificity order breaks ties, it does not pick a single winner), the customer/contract overlay precedence (Contract > Partner PriceOverlay > Catalog base), and the bounded-composition anti-drift cap. (PRD §6.4 (steps 4-5)) +- [`05-commitments-reservations.md`](./05-commitments-reservations.md) — **Commitments & Reservations** — Step 6: commitment-pool waterfall drawdown / overage and the structured `TrueUpObligation`; reservation consumption-flavor (matched usage at the reserved rate) and capacity-flavor (`capacityCharge` on allocated quantity), with the reserved-rate sourcing split (self-service from snapshot, negotiated from Contracts). (PRD §6.6 (step 6)) +- [`06-coupons.md`](./06-coupons.md) — **Coupons** — Step 7: coupon application order relative to overlays / commitment / FX, the `exclusive_best` vs `ordered_stack` stacking policies, per-`applyScope` attachment, and fail-closed on missing coupon policy. (PRD §6.8 (step 7)) +- [`07-currency-fx.md`](./07-currency-fx.md) — **Multi-Currency & FX** — Step 8: price / billing / presentment currency separation, the per-window rate-lock and invoice-period FX policies (provisional amount + delta at close), and `fxTableVersion` recording. (PRD §6.9 (step 8)) +- [`08-retroactivity-corrections.md`](./08-retroactivity-corrections.md) — **Retroactivity & Corrections** — Posted-period immutability (delta-only corrections), open-period late-arrival re-resolution **replayed strictly from the pinned snapshot** (no live catalog read), and deterministic reversal of correcting / negative usage (commitment-pool refill, tier-counter decrement) without driving a line negative. (PRD §6.10) +- [`09-period-plan-change.md`](./09-period-plan-change.md) — **Period & Plan-Change Obligations** — The period-level phases outside the per-line order: the `PeriodFloorCapObligation` (Billing-executed), mid-cycle proration on window activation, and the plan-change proration split — honoring the canonical `prorationBasis` and `billingAnchorPolicy` + D-20 clamp; Rating consumes, never decides, the change mode. (PRD §6.11) +- [`10-governance-asc606.md`](./10-governance-asc606.md) — **Governance & ASC 606** — Publish governance: Rating **registers its four fail-closed validators into the single pricing Slice 5 approval engine** (not a second workflow); ASC 606 traceable `performanceObligationRef` / `sspSnapshotPointer` (null at MVP); and the effective rev-share pass-through for bundle `sum_of_parts` (reads `effective_share_bp` only). (PRD §6.12) +- [`11-consumer-contracts.md`](./11-consumer-contracts.md) — **Consumer & Integration Contracts** — The integration surface: the Rating handoff (resolved outcome + `pricingSnapshotRef` + obligations), the Pricing read-model input contract (enums adopted verbatim; bundle summing; snapshot replay), the Subscriptions input contract, and the Finance-FX / Promotions-coupon / Billing-`periodState` contracts. (PRD §9) +- [`12-usage-ingestion-normalization.md`](./12-usage-ingestion-normalization.md) — **Usage Ingestion & Normalization** *(pipeline)* — raw usage intake, normalization to `UsageRecord`, continuous-duration session merge, authoritative usage dedup, correction ingestion. (PRD §9.2, §6.1) +- [`13-q-store-attribution.md`](./13-q-store-attribution.md) — **Windowed Q Store & Attribution** *(pipeline)* — the windowed counter `Q` (single-writer per `(subscription, meter, dimensionKey, window)`, SEAMS M7), per-slice attribution + `bandOffsetQ` (T-D-12), re-materialization and reversal decrements. (PRD §9.2) +- [`14-unit-synthesis-period-tick.md`](./14-unit-synthesis-period-tick.md) — **Unit Synthesis & Period Tick** *(pipeline)* — synthesis of the three evaluation-unit kinds incl. period-driven units at anchor boundaries (T-D-15), frozen-context assembly under the read-model pin discipline, cascade re-resolution routing (T-D-10 / T-D-12). (PRD §9.2) +- [`15-rated-output-balance-effects.md`](./15-rated-output-balance-effects.md) — **Rated Output, Delta Dedup & Balance Effects** *(pipeline)* — rated-output persistence, the outcome → RatedCharge/BillableItem mapping (ratifies slice 11 §4.1), delta dedup by correction key (T-D-11), `CommitmentBalanceEffect` publication to Contracts (T-D-10). (PRD §9.2) +- [`16-billing-handoff-operations.md`](./16-billing-handoff-operations.md) — **Billing Handoff & Operations** *(pipeline)* — the outbound Billing contract (full-precision amounts + obligations + rounding-policy id), `periodState` relay, partitioning/backpressure/replay topology, NFR verification. (PRD §9.2, §7.1) + +## Slice map (PRD §6 / §17.1 step ↔ slice) + +| Doc | PRD §6 | Seams owned | +|-----|--------|-------------| +| `01-foundation` | §6.1, §17.1 core | K1, K3, S1, M7, W2, M11 | +| `02-selection-eligibility` | §6.3 (steps 1-2), §6.5 | K1, K2, K4, K5, F1 | +| `03-metering-models` | §6.2, §6.5, §6.7 | M1, M2, M3, M5, M6, M7, M10, M11 | +| `04-overlays-precedence` | §6.4 (steps 4-5) | O1, O2, O3 | +| `05-commitments-reservations` | §6.6 (step 6) | M8, M9 | +| `06-coupons` | §6.8 (step 7) | (no scope-key seam) | +| `07-currency-fx` | §6.9 (step 8) | S1 (fx-lock segment) | +| `08-retroactivity-corrections` | §6.10 | W2 | +| `09-period-plan-change` | §6.11 | P1, P2 | +| `10-governance-asc606` | §6.12 | G1, B1 (rev-share pass-through) | +| `11-consumer-contracts` | §9 | C1, S1, B1 | +| `12-usage-ingestion-normalization` *(pipeline)* | §9.2 | — | +| `13-q-store-attribution` *(pipeline)* | §9.2 | M7 (writer side) | +| `14-unit-synthesis-period-tick` *(pipeline)* | §9.2 | — (T-D-15) | +| `15-rated-output-balance-effects` *(pipeline)* | §9.2 | — (T-D-10/11) | +| `16-billing-handoff-operations` *(pipeline)* | §9.2 | — | + +The numeric prefix follows the **§17.1 evaluation order** (steps 1-9), then the period-level and +cross-cutting slices; slices 12–16 are the **operational pipeline** (ADR-0002). See +[`../DESIGN.md`](../DESIGN.md) for the dependency graph. diff --git a/gears/bss/subscriptions/docs/ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md b/gears/bss/subscriptions/docs/ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md new file mode 100644 index 000000000..0041035c3 --- /dev/null +++ b/gears/bss/subscriptions/docs/ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md @@ -0,0 +1,109 @@ +--- +status: proposed +date: 2026-07-15 +decision-makers: "BSS Subscriptions team" +--- + +# ADR-0001: Keep the Manifest Status Enum Closed (Trials, Pause, and Intents Are Attributes) + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Add trial / paused / pending statuses to the enum](#add-trial--paused--pending-statuses-to-the-enum) + - [Model them as attributes, postures, and pending intents on the closed enum (chosen)](#model-them-as-attributes-postures-and-pending-intents-on-the-closed-enum-chosen) + - [Split the state across two machines (commercial vs billing)](#split-the-state-across-two-machines-commercial-vs-billing) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine` + +> **Status: proposed / flagged for veto.** Autonomous design decision recording the rationale behind +> [`../DECISIONS.md`](../DECISIONS.md) SUB-D-03 and SUB-D-05; ratified when Product/Architecture +> confirms. + +## Context and Problem Statement + +The BSS manifest §4.3 fixes `Subscription.status` to exactly **`draft | active | suspended | +cancelled | archived`**. Real commercial lifecycles need more distinctions than five states name +directly: a **trial** period, a **billing-only pause** (service running, collection stopped for +hardship/dispute), a **scheduled cancel** ("cancel at period end"), and enterprise **acceptance** +timing (booking vs service vs customer acceptance). The naïve response is to grow the enum +(`trial`, `paused`, `pending_activation`, …). Every added status multiplies the transition/guard +matrix, breaks manifest conformance, and forces every downstream consumer keying on `status` to +learn the new value fail-closed. + +How do we represent trials, billing-only pause, scheduled intents, and acceptance timing **without** +adding a status? + +## Decision Drivers + +* Manifest conformance: the enum is normative and closed; a new status requires a manifest change **first**, which we do not own on this branch. +* A closed, terminal state machine is what makes lifecycle audit and replay tractable — each added state roughly squares the edge/guard surface. +* Downstream consumers (rating, Billing, OSS, Analytics) key on `status`; an additive status silently breaks a fail-closed consumer until it is taught the value. +* The needed distinctions are **orthogonal to** the commercial state: a trial subscription is genuinely `active`; a collection-paused one is genuinely `active`; a scheduled cancel is genuinely `active` until it fires. + +## Considered Options + +1. Add `trial` / `paused` / `pending_*` statuses to the enum. +2. Model them as **attributes, postures, and pending intents** on the closed enum (chosen). +3. Split the state across two machines (a commercial machine + a billing machine). + +## Decision Outcome + +Chosen option: **model every extra distinction as an attribute/posture/pending-intent on the closed +manifest enum.** A **trial** is the leading plan **phase** on a manifest status (`draft` pre-paid- +activation, `active` under trial service) — SUB-D-05's sibling, §6.1 `fr-trials-not-a-status`. +**Billing-only pause** is the `collectionPaused` posture attribute on `active` (SUB-D-03). A +**scheduled cancel/resume** is a `ScheduledIntent` (`cancelMode`/`resumeAt`) pending on the aggregate +(SUB-D-01). The **activation trio** (`contractEffectiveAt`/`serviceActivatedAt`/`customerAcceptedAt`) +are attributes, not `pending_activation`/`pending_acceptance` states (SUB-D-05). Adding a status +value is out of scope until the manifest enum is amended. + +### Consequences + +* Manifest conformance is preserved; the state machine stays five states with a bounded edge/guard matrix (Foundation slice §4.1). +* Downstream `status` consumers are unaffected by trials/pause/intents — those ride attributes + events the consumers already parse. +* The cost moves to attribute/event modelling: trial phase, `collectionPaused` window, and pending intents each need clear semantics and events (owned by slices 06, 04, 01). +* If a genuine new *state* is ever required, it is a deliberate manifest change — not an ad-hoc gear extension. + +### Confirmation + +* The `status` domain type is a closed enum in code; a `trial`/`paused`/`pending` value is unrepresentable (compile-time). +* A conformance fixture: a trial subscription, a `collectionPaused` subscription, and one with a pending end-of-term cancel all report a manifest status and never a bespoke value; downstream consumers route them without a new status branch. + +## Pros and Cons of the Options + +### Add trial / paused / pending statuses to the enum + +* Good: each distinction is directly visible in `status`. +* Bad: breaks manifest conformance; squares the guard matrix; forces every consumer to learn each value fail-closed; a manifest change we do not own. + +### Model them as attributes, postures, and pending intents on the closed enum (chosen) + +* Good: manifest-conformant; bounded state machine; consumers unaffected; audit/replay stay tractable; distinctions are orthogonal to commercial state and belong on attributes. +* Bad: attribute/event semantics must be specified carefully (a posture is not a state); a reader must consult attributes, not `status` alone, to see "on trial" / "collection paused". + +### Split the state across two machines (commercial vs billing) + +* Good: separates collection concerns from commercial state. +* Bad: two machines to keep consistent; not what the manifest models; over-engineered for a single `collectionPaused` posture. + +## More Information + +Cross-gear seam analysis and the neighbour impact are in [`../SEAMS.md`](../SEAMS.md) (SUB-D +decisions surface in seams SUB-B2, SUB-C4, SUB-N1). Decisions: [`../DECISIONS.md`](../DECISIONS.md) +SUB-D-01/03/05. + +## Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.1 (`fr-status-enum`, `fr-trials-not-a-status`, `fr-scheduled-intents`, `fr-activation-instants`), §6.4 (`fr-collection-pause`), §15. +- **Decisions**: [`../DECISIONS.md`](../DECISIONS.md) SUB-D-01, SUB-D-03, SUB-D-05. +- **Design**: [`../design/01-foundation-lifecycle.md`](../design/01-foundation-lifecycle.md) §4.1–§4.4. diff --git a/gears/bss/subscriptions/docs/ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md b/gears/bss/subscriptions/docs/ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md new file mode 100644 index 000000000..5b4c44fed --- /dev/null +++ b/gears/bss/subscriptions/docs/ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md @@ -0,0 +1,109 @@ +--- +status: proposed +date: 2026-07-15 +decision-makers: "BSS Subscriptions team" +--- + +# ADR-0002: Subscriptions Owns the Change Boundary; Rating Owns the Math + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Subscriptions computes proration (self-contained)](#subscriptions-computes-proration-self-contained) + - [Subscriptions owns only the boundary; rating owns the math (chosen)](#subscriptions-owns-only-the-boundary-rating-owns-the-math-chosen) + - [A third proration service](#a-third-proration-service) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-subscriptions-adr-when-not-math-split` + +> **Status: proposed / flagged for veto.** Records the rationale for the WHEN/MATH split asserted in +> [`../PRD.md`](../PRD.md) §6.3 and seam SUB-R1; ratified when Product/Architecture confirms. Note: +> the sibling pricing gear's working tree previously carried "proration math owned by Subscriptions" +> wording in ~11 places — corrected to this split on 2026-07-15. + +## Context and Problem Statement + +A plan change, add-on change, or seat change splits a billing period at an instant and prorates the +recurring component across the two sub-intervals. Two questions must have **one owner each**: *when* +does the change take effect (`changeEffectiveAt`, `changeMode`), and *how much* is the prorated +amount (day-count basis, tier-`Q` carry-vs-reset, override resolution, FX). If both live in one gear, +or if the boundary and the math are owned by different gears that each re-derive the other's input, +replay and cross-gear reconciliation drift: the same change can rate differently depending on which +gear's clock or basis is consulted. + +Where does the boundary live, and where does the arithmetic live, so that a change replays +byte-identically? + +## Decision Drivers + +* Determinism: rating replays posted and open periods from a frozen snapshot; the proration math must be a pure function of frozen inputs + **one** authoritative boundary instant. +* Single SoR per fact: the change boundary is subscription lifecycle state; the proration basis (`prorationBasis`, `billingAnchorPolicy`) is catalog-published and rating-evaluated. +* No double math: if Subscriptions and rating both compute proration, the two must agree forever — a standing reconciliation liability. +* Posted-invoice immutability: proration must surface as new/adjusting artifacts (Billing), never an edited posted line — which only works if one gear owns the boundary the artifacts hang off. + +## Considered Options + +1. Subscriptions computes proration itself (self-contained lifecycle + billing math). +2. Subscriptions owns only the boundary/mode; rating owns all proration math (chosen). +3. Extract a third, shared proration service both gears call. + +## Decision Outcome + +Chosen option: **Subscriptions owns the WHEN, rating owns the MATH.** Subscriptions sets +`changeEffectiveAt` + `changeMode` (incl. the up/down asymmetry) and emits them on +`SubscriptionPlanChanged` / the composition-changing quantity event; the **rating gear** rates +`planA` over `[periodStart, changeEffectiveAt)` and `planB` over `[changeEffectiveAt, periodEnd)` +(half-open UTC), prorates the recurring component on the frozen `prorationBasis`, applies tier-`Q` / +commitment carry-vs-reset per the snapshot, and slices usage at the **same** boundary. Subscriptions +specifies no day-count math and no override resolution; it fixes only the trigger, the `effectiveFrom` +semantics, and the "no posted-invoice mutation" invariant. + +### Consequences + +* One boundary owner + one math owner ⇒ a change replays deterministically; there is no second clock or basis to disagree. +* Subscriptions stays free of monetary computation (constraint `no-money`), keeping the lifecycle gear auditable and the math gear the single arithmetic authority. +* Proration always materialises as new billable / credit-debit artifacts keyed off the shared boundary — posted lines are never edited. +* Subscriptions must expose the boundary + composition read model on the shared ordering key `(orderingTenantId, subscriptionId)` — the tenant pinned at creation, immutable across transfers (SUB-D-06); rating consumes it (SUB-R1 counterpart already written in rating PRD §9.2). + +### Confirmation + +* A joint proration fixture: an immediate mid-cycle upgrade with a posted invoice for the partial period yields a rating-computed adjustment off the Subscriptions boundary, with no edit to the posted line, and replays identically. +* The Subscriptions-emitted `(changeEffectiveAt, changeMode)` and the rating slice boundary are byte-identical in the shared fixtures; Subscriptions contains no day-count code path. + +## Pros and Cons of the Options + +### Subscriptions computes proration (self-contained) + +* Good: one gear to trace for a change. +* Bad: duplicates rating's evaluation engine + `prorationBasis`/`billingAnchorPolicy` frozen state; two arithmetic authorities to reconcile forever; contradicts rating being the math SoR. + +### Subscriptions owns only the boundary; rating owns the math (chosen) + +* Good: deterministic replay; single arithmetic authority; lifecycle gear stays money-free; posted-immutability holds naturally. +* Bad: requires a tight boundary contract + shared ordering key across two gears (already frozen as SUB-R1). + +### A third proration service + +* Good: neither gear owns the math. +* Bad: a new deployable + contract for a computation rating already performs; more moving parts, weaker determinism ownership; no product driver. + +## More Information + +Cross-gear seam SUB-R1 (WHEN/MATH split + shared boundary) and SUB-R3 (seat boundary) in +[`../SEAMS.md`](../SEAMS.md); rating side in [rating PRD](../../../rating/docs/PRD.md) §6.11, §9.2 and +rating design slice `09-period-plan-change`. + +## Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.3 (`fr-plan-change-boundary`, `fr-proration-ownership`, `fr-proration-triggers`, `fr-update-quantity`), §6.8 (no-retro). +- **Seams**: [`../SEAMS.md`](../SEAMS.md) SUB-R1, SUB-R3. +- **Design**: [`../design/03-plan-changes.md`](../design/03-plan-changes.md) §4.1–§4.2. diff --git a/gears/bss/subscriptions/docs/ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md b/gears/bss/subscriptions/docs/ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md new file mode 100644 index 000000000..b307e13a7 --- /dev/null +++ b/gears/bss/subscriptions/docs/ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md @@ -0,0 +1,108 @@ +--- +status: proposed +date: 2026-07-15 +decision-makers: "BSS Subscriptions team" +--- + +# ADR-0003: Scheduled Intents Live on the Aggregate (Not Portal-Side Automation) + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Portal-side automation (cron re-keys the transition)](#portal-side-automation-cron-re-keys-the-transition) + - [Pending intents on the aggregate (chosen)](#pending-intents-on-the-aggregate-chosen) + - [A native SubscriptionSchedule aggregate](#a-native-subscriptionschedule-aggregate) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate` + +> **Status: proposed / flagged for veto.** Records the rationale for [`../DECISIONS.md`](../DECISIONS.md) +> SUB-D-01 and SUB-D-04; ratified when Product/Architecture confirms. + +## Context and Problem Statement + +The most common self-service intent is "**cancel at period end**" (Stripe `cancel_at_period_end`, +Zuora cancellation policies); "suspend until a date" and committed multi-step **ramps** (Zuora +Ramps/Orders) are the enterprise equivalents. None of these is an *immediate* transition — they are a +**future** transition the customer commits to **now**. If the future firing is implemented as +portal-side automation (a cron job that re-keys the real transition at the due date), the commitment +is **invisible** to the renewal job, Billing, and audit until it fires: the renewal job may auto-renew +a subscription the customer already asked to cancel, and there is no auditable record of the standing +intent. + +Where does a committed-but-future transition live so the renewal job, Billing, and audit can all see +it before it fires? + +## Decision Drivers + +* The renewal job MUST see a pending end-of-term cancel and **suppress** the renewal + next-term recurring — otherwise it double-acts against the customer's intent. +* Billing and audit need the standing intent as first-class, auditable state — not a hidden scheduler row in a portal. +* A scheduled transition must run the **full guard set** (Policy/OSS) at its effective instant — it is not a pre-authorised bypass. +* Ramps are negotiated commitments that belong with the other negotiated commitments (Contracts SoR); this gear should *execute* well-formed intents, not *author* schedules. + +## Considered Options + +1. Portal-side automation (a cron re-keys the transition at the due date). +2. **Pending intents on the aggregate** (`cancelMode`/`resumeAt`; ramp steps) (chosen). +3. A native `SubscriptionSchedule` aggregate that owns multi-step schedules. + +## Decision Outcome + +Chosen option: **pending intents live on the aggregate.** `cancel` accepts +`cancelMode ∈ {immediate, end_of_term, at(date)}` and `suspend` MAY carry `resumeAt`; a non-immediate +intent is a `ScheduledIntent` row on the aggregate — visible to the renewal job (a pending +end-of-term cancel **suppresses** renewal + next-term recurring), auditable, and **un-schedulable** +until it fires; scheduling and un-scheduling both emit events. At `effectiveAt` the real transition +runs through the full Foundation guard set (Policy/OSS). **Ramps** (SUB-D-04) reuse this mechanism: +Contracts authors the committed schedule, which materialises here as a sequence of scheduled +`changePlan`/`updateQuantity` intents — there is **no native schedule aggregate** at launch. + +### Consequences + +* The renewal job, Billing, and audit all see the standing intent; a pending cancel can no longer be silently auto-renewed over. +* One scheduling mechanism (`IntentScheduler`, Foundation slice §4.3) serves cancel/resume/ramp — uniform idempotency, eventing, and guard replay. +* Ramps stay Contract-owned (authoring) + Subscriptions-executed (well-formed intents), mirroring the commitment-pool ownership split (rating T-D-14). +* Atomic multi-action submission (Zuora-Orders-style — submit a whole ramp as one order) is **not** covered by this mechanism; it is a Contracts/Design follow-up (SUB-C2, §15). + +### Confirmation + +* A fixture: a subscription with a pending end-of-term cancel is **not** auto-renewed and emits no next-term recurring; un-scheduling before the term end restores normal renewal; both scheduling and un-scheduling emit events. +* A fixture: a scheduled `changePlan` fires at `effectiveAt` through the full Policy/OSS guard set (a Policy deny at firing time leaves the state unchanged). + +## Pros and Cons of the Options + +### Portal-side automation (cron re-keys the transition) + +* Good: no aggregate change. +* Bad: the intent is invisible to the renewal job/Billing/audit until it fires; auto-renewal races the cancel; no auditable standing-intent record; each portal reinvents it. + +### Pending intents on the aggregate (chosen) + +* Good: visible to renewal/Billing/audit; one uniform scheduling mechanism; full guard replay at firing; ramps reuse it without a new aggregate. +* Bad: the aggregate carries pending-intent state + its own scheduler singleton; does not by itself give atomic multi-action orders. + +### A native SubscriptionSchedule aggregate + +* Good: rich multi-step scheduling, atomic orders. +* Bad: duplicates Contracts' role as the home of negotiated commitments; a large new aggregate for a launch that only needs single scheduled intents + Contract-authored ramps; revisit only if self-service ramps become a product goal. + +## More Information + +Cross-gear seams SUB-C2 (ramps: Contracts authors / Subscriptions executes) and SUB-B2 +(`collectionPaused` reuses the same pause mechanism) in [`../SEAMS.md`](../SEAMS.md). Decisions: +[`../DECISIONS.md`](../DECISIONS.md) SUB-D-01, SUB-D-04. + +## Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.1 (`fr-scheduled-intents`), §6.3 (`fr-ramp-execution`), §6.5 (renewal suppression), §15 (atomic multi-action open). +- **Decisions**: [`../DECISIONS.md`](../DECISIONS.md) SUB-D-01, SUB-D-04. +- **Design**: [`../design/01-foundation-lifecycle.md`](../design/01-foundation-lifecycle.md) §4.3, [`../design/03-plan-changes.md`](../design/03-plan-changes.md) §4.5. diff --git a/gears/bss/subscriptions/docs/DECISIONS.md b/gears/bss/subscriptions/docs/DECISIONS.md new file mode 100644 index 000000000..73f5ae02a --- /dev/null +++ b/gears/bss/subscriptions/docs/DECISIONS.md @@ -0,0 +1,111 @@ + + + +# Subscriptions — Decisions Log + +Decision IDs are `SUB-D-NN`. Autonomous decisions follow the pricing-gear pattern: adopted into +the docs immediately, **flagged for veto** until Product/Architecture confirms. Severity: +H (high — commercial/model shape), M (medium), L (low). + +## Status board + +| ID | Sev | Decision | Status | +|----|-----|----------|--------| +| SUB-D-01 | M | Scheduled lifecycle intents: `cancelMode { immediate, end_of_term, at(date) }` + `resumeAt`, pending intents on the aggregate | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-02 | M | `updateQuantity` as a first-class transition with the plan-change envelope; increases immediate, decreases next-cycle by default | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-03 | M | Billing-only pause = `collectionPaused` posture on `active` (subscription attribute, collection-scoped) | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-04 | M | Ramps: Contracts authors the committed schedule; Subscriptions executes generated scheduled intents; no native schedule aggregate | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-05 | M | Activation date trio (booking / service / acceptance) as attributes + events; no new statuses | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-06 | H | Ordering tenant pinned at creation: transfer rebinds commercial axes, never the ordering/partition key | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-07 | H | Recurring split: Subscriptions emits a money-free period fact; rating prices the recurring line; Billing posts | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-08 | M | Mutation-type inventory completed: `renew`, `unschedule`, `pauseCollection`/`resumeCollection`, `confirmAcceptance`, `extendTrial` | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-09 | M | Secondary producer-event inventory named in design (intents, renewal/grace, notices, pause, quantity, conversion, quota) | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-10 | M | Entitlement check surface: bounded-staleness degraded mode (last-known-good ≤ budget, then fail-closed) | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-11 | M | `draft → cancelled` (void) edge added; draft is exitable without activation | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | +| SUB-D-12 | M | `collectionPaused` suspends renewal payment pre-check + grace entry for in-window renewals (term extension continues) | **DECIDED (autonomous) 2026-07-15 · flagged for veto** | + +## Decisions + +#### SUB-D-01 [M] Scheduled lifecycle intents (cancel at term end / at date; scheduled resume) + +- **Where**: [PRD](./PRD.md) §6.1 `fr-scheduled-intents`, §6.5 interaction, AC 21–22; source: [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-1. +- **Problem**: `changeMode` existed only for `changePlan`; "cancel at period end" (the most common self-service intent; Stripe `cancel_at_period_end`, Zuora cancellation policies, the predecessor's own glossary "Cancellation Policy") and "suspend until date" were unrepresentable — portal-side cron hacks would be invisible to the renewal job, Billing, and audit. +- **Options**: (a) pending-intent envelope on the aggregate reusing the §6.3 mode vocabulary; (b) status quo (portal automation). +- **Decision**: **(a), 2026-07-15** — `cancelMode ∈ {immediate, end_of_term, at(date)}`, `resumeAt` on suspend; pending intents stored on the aggregate, suppress renewal + next-term recurring, un-schedulable until effective, evented both ways; the effective-instant transition runs the full §6.1 guard set. *Flagged for veto.* **Propagated**: PRD §6.1 FR + §5.1 row + glossary (Cancellation policy) + AC 21–22 + §15 manifest-envelope row; gap analysis G-1 → actioned. + +#### SUB-D-02 [M] `updateQuantity` as a first-class transition + +- **Where**: [PRD](./PRD.md) §6.3 `fr-update-quantity`, §6.1 type list, AC 23; source: gap analysis G-3. +- **Problem**: pricing D-18 (`quantitySource = subscription_seat_count`) makes this gear the seat-count supplier, but the transition inventory had no quantity operation — no Policy gate, no proration boundary, no provenance for the number rating reads (Stripe `items.quantity`, Zuora `UpdateProduct` both first-class). +- **Options**: (a) full transition with the §6.3 envelope; (b) attribute-with-audit only (next-cycle effect). +- **Decision**: **(a), 2026-07-15**, with the up/down asymmetry defaulting to (b)'s conservatism: **increases MAY be immediate** (prorated by rating at the boundary), **decreases default `next-cycle`**. Seat counts consumed by rating MUST originate from committed transitions only. Policy-gated when quota-bearing; composition-changing event under AC 11 rules. *Flagged for veto.* **Propagated**: PRD §6.3 FR + §6.1 type list + §5.1 row + AC 23 + §15 manifest-type row; gap analysis G-3 → actioned. + +#### SUB-D-03 [M] Billing-only pause = `collectionPaused` posture on `active` + +- **Where**: [PRD](./PRD.md) §6.4 `fr-collection-pause`, glossary (Subscription pause), AC 24; source: gap analysis G-4. +- **Problem**: suspension always touches service (entitlement freeze, OSS pause); the inverse — service running, collection paused (hardship/dispute/goodwill; Stripe `pause_collection`; the predecessor's "Subscription Pause" P1 scope) — was unrepresentable without abusing `suspended`. +- **Options**: (a) subscription **attribute posture** on `active` (rides subscription events and renewal logic; reuses the §6.5 recurring-pause mechanism); (b) Billing-side AR hold (outside the aggregate). +- **Decision**: **(a), 2026-07-15** — `collectionPaused` auditable window (start/end/limit/reason) on `active`; collection-scoped only (service, entitlements, renewal evaluation untouched unless a separate intent says otherwise); Billing chooses the artifact treatment per policy. Pause-day limits and resume-proration mechanics stay open (§15) with Product/Billing. *Flagged for veto — Finance may still prefer (b); the aggregate-posture shape keeps the audit trail on the subscription either way.* **Propagated**: PRD §6.4 FR + §5.1 row + glossary + AC 24 + §15 mechanics row; gap analysis G-4 → decided. + +#### SUB-D-04 [M] Ramps are Contract-authored; Subscriptions executes scheduled intents + +- **Where**: [PRD](./PRD.md) §6.3 `fr-ramp-execution`; source: gap analysis G-5. +- **Problem**: committed multi-step growth (Zuora Ramps/Orders, Stripe schedules) had no representation; a native schedule aggregate would duplicate Contracts' role as the home of negotiated commitments (cf. rating T-D-14 pools). +- **Options**: (a) Contracts authors the committed ramp, materialized as a sequence of scheduled `changePlan`/`updateQuantity` intents executed here; (b) native `SubscriptionSchedule` aggregate; (c) status quo (operator re-keys). +- **Decision**: **(a), 2026-07-15** — depends on the SUB-D-01/02 envelopes; atomic multi-action submission is a Contracts/Design follow-up (§15). (b) revisited only if self-service ramps become a product goal. *Flagged for veto.* **Propagated**: PRD §6.3 FR + §15 Contracts-follow-up row; gap analysis G-5 → decided. + +#### SUB-D-05 [M] Activation date trio as attributes; no new statuses + +- **Where**: [PRD](./PRD.md) §6.1 `fr-activation-instants`, AC 25; source: gap analysis G-6. +- **Problem**: `activate` was a single instant; Zuora's `ContractEffectiveDate` / `ServiceActivationDate` / `CustomerAcceptanceDate` distinction — which the ASC 606 hooks need for enterprise acceptance clauses — was collapsed. +- **Options**: (a) three instants as attributes/evaluated fields + an optional acceptance-confirmation operation; (b) manifest enum extension (pending-activation / pending-acceptance interim statuses); (c) purely Contracts/Finance-side dates. +- **Decision**: **(a)+(c) split, 2026-07-15** — `contractEffectiveAt` referenced from Contract (booking semantics stay Contracts/Finance SoR), `serviceActivatedAt` stamped at the activate commit, `customerAcceptedAt` stamped by an acceptance confirmation where Contract clauses require (else = service activation); all three ride lifecycle events and ASC hooks. (b) rejected — the manifest enum stays closed, `draft` covers pre-activation. Confirmation-flow shape → Design (§15). *Flagged for veto.* **Propagated**: PRD §6.1 FR + AC 25 + §15 acceptance-flow row; gap analysis G-6 → decided. + +#### SUB-D-06 [H] Ordering tenant pinned at creation (transfer never rebinds the ordering key) + +- **Where**: [PRD](./PRD.md) §6.7 `fr-event-ordering`, AC 26; design slices 01 §4.2, 07 §4.4; seam SUB-R1. +- **Problem**: the ordering/partition key is `(resourceTenantId, subscriptionId)` and storage is partitioned by `resourceTenantId`; an ownership transfer that rebinds `resourceTenantId` would switch the key mid-stream — pre/post-transfer events land in different partitions, rating's ordered consumption breaks exactly at the most sensitive moment, and the aggregate row set (revisions, intervals, entitlements, audit) would need a physical partition migration. The design review (2026-07-15) flagged this as its top finding. +- **Options**: (a) pin an immutable `orderingTenantId` at creation (= `resourceTenantId` at creation) used for ordering + partitioning; transfer rebinds the commercial axes only; (b) ordering-epoch/barrier protocol that re-keys the stream transactionally; (c) forbid `resourceTenantId` transfer (cancel+new only). +- **Decision**: **(a), 2026-07-15** — `orderingTenantId` is stamped at creation and immutable; the manifest ordering invariant reads onto it; `OwnershipTransferCompleted` carries both old and new axes **on the same partition**, so consumers re-key their own projections without a stream barrier. (b) rejected as a heavy protocol with no consumer that needs it; (c) rejected — transfer is a manifest §4.11 flow. *Flagged for veto.* **Propagated**: PRD §6.7 FR + AC 26; slice 01 §4.2, slice 07 §4.1/§4.4; SEAMS SUB-R1 note + ownership matrix. + +#### SUB-D-07 [H] Recurring split: Subscriptions emits the period fact, rating prices it + +- **Where**: [PRD](./PRD.md) §6.8 `fr-recurring-idempotency`, §9.2 billing handoff + rating read-model, §4 diagram, AC 27; design slice 08 §4.3; seams SUB-B1, SUB-R1. +- **Problem**: slice 08's `RecurringEmitter` sends `BillableItemCreated(kind=recurring)` straight to Billing, but this gear computes no money — while the rating PRD evaluates recurring components itself (flat per-period, `per_unit = unitPrice × seats`, hybrid emits recurring + usage as two lines). Two candidate producers of the recurring line ⇒ double-emission or an amount-less item Billing cannot post; no seam pinned the split. +- **Options**: (a) Subscriptions emits a **money-free recurring period fact** (period identity + traceability tuple + `pricingSnapshotRef`); rating consumes it as the period trigger, evaluates the recurring amount from the frozen snapshot, and emits the priced line to Billing; (b) Subscriptions prices recurring itself (violates WHEN/MATH); (c) rating self-triggers recurring off its own calendar (duplicates the anchor/pause/intent logic that lives here). +- **Decision**: **(a), 2026-07-15** — the WHEN/MATH split extended to recurring: this gear owns the period cut (anchor, pauses, pending intents, idempotency key `(subscriptionId, billing period)`); rating owns the price. The fact carries no monetary column; the priced line inherits the fact's idempotency key. *Flagged for veto — needs the rating counterpart contract updated (joint fixture).* **Propagated**: PRD §6.8 FR + §9.2 contracts + §4 diagram + AC 27; slice 08 §4.3; SEAMS SUB-B1/SUB-R1 + ownership matrix. + +#### SUB-D-08 [M] Mutation-type inventory completed + +- **Where**: [PRD](./PRD.md) §6.1 `fr-transition-request`, §9.1 table; design slices 01 §3.1/§4.2, 09 §4.1; seam SUB-N1. +- **Problem**: the PRD's own FRs mandate mutations that had no `TransitionRequest.type`: manual **renew** (§6.5 "requires an explicit TransitionRequest"), **un-scheduling** a pending intent (SUB-D-01, AC 22), the `collectionPaused` set/clear (SUB-D-03), **acceptance confirmation** (SUB-D-05), **trial extension** (§6.10) — breaking the "single commit path, all mutations are TransitionRequests" principle; §9.1 also lagged behind `updateQuantity`/`convertTrial`/`transfer`. +- **Options**: (a) extend the type list with `renew`, `unschedule`, `pauseCollection`, `resumeCollection`, `confirmAcceptance`, `extendTrial` (same pending-manifest-alignment posture as `updateQuantity`/`convertTrial`); (b) model them as untyped admin endpoints outside the envelope. +- **Decision**: **(a), 2026-07-15** — every mutation gets a type; `extendTrial` is approval-gated (high-risk pattern), `unschedule` references the pending intent it voids. (b) rejected — it recreates the side door the Foundation exists to close. *Flagged for veto.* **Propagated**: PRD §6.1 type list + §9.1 table + §15 manifest row; slices 01/09; SEAMS SUB-N1. + +#### SUB-D-09 [M] Secondary producer-event inventory named in design + +- **Where**: design slice 08 §4.1 (naming registry); [PRD](./PRD.md) §6.7 pointer; ACs 7/14/17/19/22/23/24. +- **Problem**: the §6.7 producer inventory repeats the manifest baseline, but PRD FRs mandate auditable events with no names anywhere: intent schedule/un-schedule (AC 22), renewal outcome + grace entry/exit (AC 7), notice triggers (AC 19), pause window (AC 24), the quantity composition event (AC 23, "naming per Design"), the first-class conversion event (AC 17), quota warning/exhausted/restored (AC 14). Slice 08 deferred the field matrix to slice 09, slice 09 deferred to "Design" — a circular deferral with no owner. +- **Options**: (a) slice 08 owns the full naming registry + required-context groups; slice 09 owns only wire mappings; (b) keep deferring to implementation PRs. +- **Decision**: **(a), 2026-07-15** — the secondary set is named normatively in slice 08 §4.1 (`SubscriptionIntentScheduled`/`…Unscheduled`, `SubscriptionRenewalSucceeded`/`…Failed`, `SubscriptionGraceEntered`/`…Exited`, `SubscriptionRenewalNoticeDue`, `SubscriptionCollectionPaused`/`…Resumed`, `SubscriptionQuantityChanged`, `SubscriptionTrialConverted`/`…Extended`/`…Expired`, `SubscriptionAcceptanceConfirmed`, `EntitlementQuotaWarning`/`…Exhausted`/`…Restored`). *Flagged for veto.* **Propagated**: slice 08 §3.3/§4.1; slice 09 §3.3; PRD §6.7 pointer sentence. + +#### SUB-D-10 [M] Check surface: bounded-staleness degraded mode + +- **Where**: design slice 05 §3.8/§4.3; [PRD](./PRD.md) §15 row; seam SUB-E3. +- **Problem**: slice 05 declared the point-of-use check "fails closed on read-model outage (never stale-allow)" while also being cache-first with a < 5s propagation baseline — caching *is* bounded staleness, so the posture was self-contradictory; and a hard fail-closed read path means one projection outage blocks every entitlement check platform-wide (availability catastrophe for a runtime read). +- **Options**: (a) explicit staleness budget: serve last-known-good decisions up to a bounded age (default 60s) on projection outage, then fail closed; transitions stay strictly fail-closed; (b) keep binary fail-closed; (c) fail-open indefinitely. +- **Decision**: **(a), 2026-07-15** — the budget makes the normal-mode cache and the degraded mode one coherent rule; the 60s default is a Product/OSS knob (§15). (b) rejected as an availability landmine; (c) rejected as a silent-overrun door. *Flagged for veto.* **Propagated**: slice 05 §3.8/§4.3; slice 09 §3.8 scoping (only the check surface carries this posture); PRD §15 budget row. + +#### SUB-D-11 [M] `draft → cancelled` (void) edge + +- **Where**: [PRD](./PRD.md) §6.1 status machine + transitions table, AC 28; design slice 01 §4.1. +- **Problem**: the state machine had no exit from `draft` — abandoned drafts (incl. never-activated trials, whose expiry path is `cancel`) were immortal, and the single-commit-path principle forbids deleting them out-of-band. +- **Options**: (a) add `draft → cancelled` (void; immediate-only, not resource-affecting — nothing is provisioned, no OSS leg); (b) hard-delete drafts by retention job; (c) leave drafts forever. +- **Decision**: **(a), 2026-07-15** — void is a normal `cancel` from `draft` with the standard envelope and audit; an optional draft-retention TTL that *submits* the void is a Product knob (§15). Same manifest posture as `resume`: the manifest lists operations, not every arrow. *Flagged for veto.* **Propagated**: PRD §6.1 enum FR + table + diagram + AC 28 + §15 TTL row; slice 01 §4.1. + +#### SUB-D-12 [M] `collectionPaused` suspends renewal collection, not renewal evaluation + +- **Where**: [PRD](./PRD.md) §6.4 `fr-collection-pause`, AC 29; design slice 04 §4.2/§4.3; seam SUB-B2. +- **Problem**: SUB-D-03 left "renewal evaluation continues" during a pause — so an auto-renewal inside a hardship/dispute pause would run its payment pre-check, fail, enter grace, and suspend the customer: the collection-only pause would cause the exact service loss it exists to avoid. +- **Options**: (a) renewal evaluation and term extension continue, but the payment pre-check, grace entry, and dunning handoff are **suspended** for renewals whose collection falls inside the pause window — they run when the window ends (deferred collection per the Billing artifact treatment); (b) block renewal entirely during pause; (c) keep pre-check + grace running (status quo). +- **Decision**: **(a), 2026-07-15** — mirrors Stripe `pause_collection` semantics (service and term continuity, collection deferred); term extension stays deterministic for rating/Billing. (b) rejected — an expiring term during pause would kill service; (c) rejected as self-defeating. *Flagged for veto.* **Propagated**: PRD §6.4 FR + AC 29; slice 04 §4.2/§4.3; SEAMS SUB-B2 note. diff --git a/gears/bss/subscriptions/docs/DESIGN.md b/gears/bss/subscriptions/docs/DESIGN.md new file mode 100644 index 000000000..5aaa8e01b --- /dev/null +++ b/gears/bss/subscriptions/docs/DESIGN.md @@ -0,0 +1,466 @@ + + + +# Technical Design — Subscriptions (End-to-End Lifecycle) + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional context](#4-additional-context) +- [5. Traceability](#5-traceability) + + + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-main` + +> **Canonical design entry point and index.** This document is the subscriptions gear's top-level +> technical design and the anchor for spec traceability. The design is authored as a **set of slice +> documents** under [`design/`](./design/): a shared **Lifecycle Foundation** (the subscription +> aggregate, the manifest-closed status machine, the `TransitionRequest` envelope with idempotency + +> ordering, effective-dated composition, the Policy/OSS gate, versioning + audit) plus per-capability +> slice designs. This page is the single index over that set — architecture overview, the slice map +> with PRD traceability, dependency order, the cross-cutting normative statements, the ADR index, and +> the traceability surface — and delegates slice-level specifics (schemas, sequences, event field +> matrices) to the slice documents so they stay the single source of truth for their detail. +> +> **Status**: skeleton — the overview, slice map, and cross-cutting normatives are authored +> pre-Design-lock; the slice documents are seeded against the seams in +> [`SEAMS.md`](./SEAMS.md) and are authored on the agreed cut below. + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The **subscriptions gear** is the BSS **System of Record** for the subscription commercial +aggregate (manifest §4.3): its **lifecycle state machine**, effective-dated **composition** +(`PlanLink`/`AddOn`), the plan-change **boundary and mode**, **renewal** execution, **entitlement** +assignment + point-of-use check state, and **multi-tenant** ownership. Given a governed transition +request, the gear validates it, gates resource-affecting effects fail-closed through the Policy +Engine, coordinates OSS provisioning, commits an **idempotent, versioned** state change, issues or +revokes entitlements, and emits ordered lifecycle events — so that **rating** can charge, **Billing** +can post, and **OSS** can provision deterministically from committed subscription state +([`PRD.md`](./PRD.md) §1.1). + +The gear is deliberately **not** an authoring catalog and it **computes no money**: the pricing gear +owns `Plan`/`Price`/`PriceWindow`/`PriceOverlay`/`CatalogVersion` and publish governance; the rating +gear owns tariff evaluation and all **proration math**; Billing owns posting, floor/cap, and +rounding. Subscriptions owns the **WHEN** of a change (`changeEffectiveAt`, `changeMode`) and never +the arithmetic — the single split that keeps replay deterministic ([`PRD.md`](./PRD.md) §6.3). + +The cross-gear complementarity — the composition read-model + change boundary, the `(currency, +region)` snapshot segment, the plan-change classification and phase/grant contracts adopted from +pricing, the renewal/grace obligation on Contracts, the Billing recurring-idempotency and +`collectionPaused` lines, the Policy/OSS gate and entitlement enforcement split — is frozen in +[`SEAMS.md`](./SEAMS.md); every slice here implements the subscriptions side of a listed seam. + +### 1.2 Architecture Drivers + +Requirements from [`PRD.md`](./PRD.md) that significantly shape the architecture. + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-status-enum` / `cpt-cf-bss-subscriptions-fr-transitions-guards` | A **closed, manifest-aligned** status machine (`draft`→`active`↔`suspended`→`cancelled`→`archived`) with terminality; every edge carries a guard, and the Foundation is the single place a transition commits ([`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md)). | +| `cpt-cf-bss-subscriptions-fr-transition-request` / `cpt-cf-bss-subscriptions-fr-scheduled-intents` | A uniform `TransitionRequest` envelope (`type`, `idempotencyKey`, `status`) gives idempotency, approval hooks, and audit one shape; non-immediate intents (`cancelMode`, `resumeAt`) are **pending intents on the aggregate** visible to the renewal job (SUB-D-01). | +| `cpt-cf-bss-subscriptions-fr-effective-dated-composition` / `cpt-cf-bss-subscriptions-fr-snapshot-discipline` | `PlanLink`/`AddOn` are **effective-dated intervals**, never destructive edits; fee artifacts carry `pricingSnapshotRef` with the Subscriptions-written `(currency, region)` segment so posted periods reproduce from frozen inputs (SUB-R2). | +| `cpt-cf-bss-subscriptions-fr-plan-change-boundary` / `cpt-cf-bss-subscriptions-fr-proration-ownership` | Subscriptions sets `changeEffectiveAt`/`changeMode` and emits `SubscriptionPlanChanged`; the **rating gear** owns proration math and usage slicing at the same boundary (SUB-R1). | +| `cpt-cf-bss-subscriptions-fr-renewal-evaluation` / `cpt-cf-bss-subscriptions-fr-grace-policy` | A Contract-driven renewal job with a testable **grace ladder** (7-day default, paused next-term recurring, hybrid exit) storing **evaluated fields** for replay; Contracts is the SoR the job consumes (SUB-C1). | +| `cpt-cf-bss-subscriptions-fr-entitlement-check-contract` / `cpt-cf-bss-subscriptions-fr-entitlement-assignment` | Entitlements are **assigned** from the pricing published grant set (incl. per-phase map, D-41); the gear serves a real-time **check decision state** (p95 < 100ms) while **OSS enforces** (SUB-P2, SUB-E3). | +| `cpt-cf-bss-subscriptions-fr-recurring-idempotency` / `cpt-cf-bss-subscriptions-fr-no-retro-edit` | `BillableItem(kind=recurring)` idempotent per `(subscriptionId, billing period)`; posted invoices immutable — corrections are new billable/adjustment artifacts (SUB-B1). | +| `cpt-cf-bss-subscriptions-fr-event-ordering` / `cpt-cf-bss-subscriptions-fr-event-payload-completeness` | Ordered per `(orderingTenantId, subscriptionId)` — pinned at creation, immutable across transfers (SUB-D-06); composition-changing events carry enough snapshot-oriented context for rating + Billing to stay aligned and idempotent — the shared ordering key with rating (SUB-R1). | +| `cpt-cf-bss-subscriptions-fr-delegation-proofs` / `cpt-cf-bss-subscriptions-fr-tenant-axes` | Three tenant axes referenced from AMS (never invented); cross-tenant mutation requires an auditable **delegation proof** or is rejected. | + +#### NFR Allocation + +Non-functional targets are in [`PRD.md`](./PRD.md) §7.1; the SLA baselines are **carried from the +predecessor** and MUST be reconciled with the program NFR workshop (the workshop overrides on +conflict — §14). + +| NFR theme | Allocated to | Design Response | Status | +|-----------|--------------|-----------------|--------| +| Lifecycle control-plane latency (p95 < 1s) | Foundation transition path | Synchronous commit class; Policy pre-check + OSS confirm inline, single versioned write | Baseline (workshop-pending) | +| Entitlement check latency (p95 < 100ms) | Entitlement check read surface | Cache-friendly, tenant-isolated projection served off the hot path; propagation to the check surface < 5s | Baseline (workshop-pending) | +| Recurring generation cut (daily by 00:00) | Recurring emission job | Idempotent per `(subscriptionId, billing period)`; coordinated singleton | Baseline (workshop-pending) | +| Proration monetary alignment (100%) | Plan-change boundary + rating | Boundary owner here, math owner rating; end-to-end alignment tested against the joint fixture | Baseline (workshop-pending) | +| Horizontal partitioning; 100K+ active subs/tenant | Aggregate store + read models | Partition by the pinned `orderingTenantId` (SUB-D-06 — stable across transfers, no row migration); bulk roll-up read models; batch Policy where contractually safe | Baseline (workshop-pending) | + +#### Key ADRs + +The load-bearing rationale is captured as ADRs (authored 2026-07-15, `status: proposed` / flagged +for veto — see [`ADR/`](./ADR/)): + +| ADR | Decision captured | +|-----|-------------------| +| [`ADR/0001`](./ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md) `cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine` | Why trials, billing-only pause, and scheduled intents are **attributes/postures/pending-intents on the closed manifest enum**, not new statuses (§6.1, SUB-D-01/03/05); the enum stays `draft`/`active`/`suspended`/`cancelled`/`archived`. | +| [`ADR/0002`](./ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md) `cpt-cf-bss-subscriptions-adr-when-not-math-split` | Why Subscriptions owns only the change **boundary/mode** and rating owns all proration math — the split that keeps replay deterministic (§6.3, SUB-R1). | +| [`ADR/0003`](./ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md) `cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate` | Why scheduled cancel/resume/ramp intents live **on the aggregate** (visible to the renewal job, Billing, audit) rather than as portal-side automation (SUB-D-01/04). | + +### 1.3 Architecture Layers + +A shared **Lifecycle Foundation** ([`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md)) +owns the subscription aggregate, the closed status machine, the `TransitionRequest` envelope with +idempotency + ordering + the Policy/OSS gate, effective-dated composition, versioning, and the audit +store. Each capability slice is a **handler** that requests transitions **through** the Foundation +under those invariants — it owns its capability policy (renewal, entitlements, trials) but never the +commit/idempotency/gate mechanics. The numeric prefix follows PRD §6 decomposition and the +dependency order in the graph below. + +| Slice | Title | PRD §6 | Seams owned | +|-------|-------|--------|-------------| +| [`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md) | Lifecycle Foundation | §6.1 | SUB-E1, SUB-C4, SUB-N1 | +| [`design/02-composition-versioning.md`](./design/02-composition-versioning.md) | Composition & Versioning | §6.2 | SUB-R2, SUB-G2 | +| [`design/03-plan-changes.md`](./design/03-plan-changes.md) | Plan & Quantity Changes | §6.3 | SUB-R1, SUB-R3, SUB-P1, SUB-C2, SUB-G1 | +| [`design/04-suspension-renewal-grace.md`](./design/04-suspension-renewal-grace.md) | Suspension, Renewal & Grace | §6.4, §6.5 | SUB-B2, SUB-C1, SUB-F1, SUB-B5, SUB-E2 | +| [`design/05-entitlements.md`](./design/05-entitlements.md) | Entitlement Lifecycle | §6.9 | SUB-P2, SUB-E3, SUB-B4 | +| [`design/06-trials.md`](./design/06-trials.md) | Trial Runtime & Conversion | §6.10 | SUB-R4, SUB-P3 | +| [`design/07-tenancy-transfer.md`](./design/07-tenancy-transfer.md) | Multi-Tenant Ownership & Transfer | §6.6 | (AMS delegation; transfer approvals) | +| [`design/08-events-billing.md`](./design/08-events-billing.md) | Event Model & Billing Alignment | §6.7, §6.8 | SUB-B1, SUB-R1 (ordering), SUB-C5 | +| [`design/09-consumer-contracts.md`](./design/09-consumer-contracts.md) | Consumer & Integration Contracts | §9 | SUB-R1, SUB-B1, SUB-C1, SUB-E1/E3, SUB-F1, SUB-G1 | + +#### Dependency order + +```text +01-foundation-lifecycle (aggregate, status machine, TransitionRequest, idempotency/ordering, Policy/OSS gate, versioning, audit) + │ + ├─→ 02-composition-versioning (effective-dated PlanLink/AddOn, snapshot segment, PlanTier) + │ │ + │ ▼ + ├─→ 03-plan-changes (change boundary/mode, updateQuantity, ramps, overlap — needs composition) + ├─→ 04-suspension-renewal-grace (suspend/resume, collectionPaused, renewal job, grace ladder) + ├─→ 05-entitlements (assignment from grant sets, check surface — needs composition + phases) + ├─→ 06-trials (phase runtime + conversion — needs composition + entitlements) + ├─→ 07-tenancy-transfer (tenant axes, delegation, transfer approvals) + ├─→ 08-events-billing (producers, ordering, recurring idempotency — projects committed state) + └─→ 09-consumer-contracts (the boundary surface over all of the above) +``` + +- `02-composition-versioning` is the substrate the commercial slices read/write; it follows the Foundation. +- `03-plan-changes` needs effective-dated composition (the boundary opens/closes `PlanLink` intervals). +- `04`, `05`, `06` are parallel capabilities over the aggregate + composition; `06-trials` builds on `05-entitlements` (per-phase grant re-issue) and the phase machinery. +- `08-events-billing` projects committed state, so it follows the capabilities that produce it. +- `09-consumer-contracts` is the integration surface over the whole gear, so it is last. + +## 2. Principles & Constraints + +The lifecycle-wide normative statements are authored in the Foundation design; they are surfaced +here as design principles/constraints with stable ids. + +### 2.1 Design Principles + +#### Manifest-closed status machine + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-manifest-closed-enum` + +`Subscription.status ∈ {draft, active, suspended, cancelled, archived}` is closed and terminal; +**trials, billing-only pause, and scheduled intents are attributes/postures/pending-intents on that +enum, never new statuses** (§6.1, SUB-D-03/05). A new status requires a manifest change first. + +#### WHEN, not math + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-when-not-math` + +Subscriptions owns the change **boundary and mode** (`changeEffectiveAt`, `changeMode`) and computes +**no** monetary charge, proration, tax, or FX. The rating gear owns the math; Billing owns posting. +One boundary owner + one math owner is the only split that keeps replay deterministic (§6.3). + +#### Idempotent, ordered transitions + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-idempotent-ordered` + +Every mutation is a `TransitionRequest` with exactly **one** durable effect per +`(subscriptionId, idempotencyKey)`; event and command order is preserved within +`(orderingTenantId, subscriptionId)` — the pinned-at-creation key shared with rating partition +ordering, immutable across ownership transfers (§6.7, SUB-D-06). + +#### Fail-closed gate + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-fail-closed-gate` + +Every **resource-affecting** transition passes the Policy Engine pre-check before commit; on **deny +or unavailability** the subscription state MUST NOT change (§6.1, SUB-E1). OSS resource topology is +mutated only via confirmed work orders — never directly. + +#### Effective-dated, reproducible composition + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-effective-dated-composition` + +Composition is a set of effective-dated intervals; history is never destructively edited. Posted +fee artifacts carry `pricingSnapshotRef` (Subscriptions writes the `(currency, region)` segment) so +Billing/rating reproduce a posted period from frozen inputs, never from live catalog state (§6.2). + +### 2.2 Constraints + +#### Not an authoring System of Record + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-not-authoring-sor` + +Subscriptions resolves **published** catalog (`Plan`/`Price`/`PriceWindow`) and registry +(`skuId`/`PlanTier`/`CatalogVersion`) facts; it authors no catalog entity, evaluates no overlay, and +computes no charge. Draft or non-sellable catalog state fails the transition guard fail-closed +(SUB-P5, SUB-G2). + +#### UTC time + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-utc-time` + +All effective dating, change boundaries, term/renewal windows, grace and notice math, and scheduled +intents are UTC. + +#### Tenant isolation; delegation-proofed cross-tenant + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-tenant-isolation` + +Subscriptions are tenant-scoped on the three axes (`resourceTenantId`, `payerTenantId`, +`sellerTenantId`) referenced from AMS — never invented (§6.6). Any cross-tenant admin action carries +an auditable **delegation proof** or is rejected with the proof reference recorded (SUB-N/AMS). + +#### Posted-document immutability + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-posted-immutability` + +Posted invoice lines are never rewritten; subscription corrections flow as **new billable or +adjustment** artifacts (§6.8, SUB-B1). Subscription state and posted financial state are separate +datasets. + +## 3. Technical Architecture + +The technical architecture is specified per slice in the [`design/`](./design/) set, with the shared +substrate in [`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md). This section +summarises the cross-slice shape and declares the component/sequence ids; the slice map and +dependency order are in §1.3. + +### 3.1 Domain Model + +_TBD (skeleton)._ The core aggregate — `Subscription` (status, `version`, tenant axes, activation +instants, composition, postures) with its child entities `PlanLink`, `AddOn`, `TransitionRequest`, +`Approval`, `Entitlement`, and the scheduled-intent / renewal-evaluation value objects — is owned by +`01-foundation-lifecycle`; per-capability shapes by their slices. Catalog and registry entities are +**resolved, frozen inputs**, not owned here. Full field-level definitions and the naming discipline +are normative in the Foundation slice §3.1. + +### 3.2 Component Model + +Components are handlers over the shared Foundation, not independently deployable services. Each +carries a stable `cpt-cf-bss-subscriptions-component-{slug}` ID; the linked slice doc is normative +for its internals. + +#### Lifecycle Foundation + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-lifecycle-foundation` + +Subscription aggregate, closed status machine + guards, `TransitionRequest` envelope with +idempotency + `(orderingTenantId, subscriptionId)` ordering (the tenant pinned at creation, SUB-D-06), the Policy/OSS fail-closed gate, +versioning, activation instants, and the audit store +([`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md)). + +#### Composition & Versioning handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-composition-versioning` + +Effective-dated `PlanLink`/`AddOn` intervals, monotonic `version`, the `(currency, region)` +`pricingSnapshotRef` segment, `PlanTier` derivability, per-sale brand attribution +([`design/02-composition-versioning.md`](./design/02-composition-versioning.md)). + +#### Plan & Quantity Changes handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-plan-changes` + +Change boundary/mode, up/down asymmetry, `updateQuantity` seat provenance, ramp execution of +scheduled intents, overlap-cardinality detection, backdating guards +([`design/03-plan-changes.md`](./design/03-plan-changes.md)). + +#### Suspension, Renewal & Grace handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-suspension-renewal-grace` + +Suspend/resume, `collectionPaused` posture, the renewal job (auto/manual), notices + opt-out, the +failed-renewal grace ladder with evaluated fields +([`design/04-suspension-renewal-grace.md`](./design/04-suspension-renewal-grace.md)). + +#### Entitlement Lifecycle handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-entitlements` + +Issue/revoke from transitions, assignment from the pricing grant set (incl. per-phase), the +point-of-use check decision state (p95 < 100ms), quota soft/hard limits +([`design/05-entitlements.md`](./design/05-entitlements.md)). + +#### Trial Runtime & Conversion handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-trials` + +Trial provisioning on the phase machinery, end-of-trial + early `convertTrial`, expiry, extension +([`design/06-trials.md`](./design/06-trials.md)). + +#### Multi-Tenant Ownership & Transfer handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-tenancy-transfer` + +Tenant-axis resolution from AMS, delegation-proof enforcement, hierarchy by reference, and the +approval-gated ownership transfer flow +([`design/07-tenancy-transfer.md`](./design/07-tenancy-transfer.md)). + +#### Event Model & Billing Alignment handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-events-billing` + +The producer inventory + payload-sufficiency rules, ordering, recurring `BillableItem` idempotency, +traceability, and the event outbox +([`design/08-events-billing.md`](./design/08-events-billing.md)). + +#### Consumer & Integration Contracts handler + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-consumer-contracts` + +The integration surface: Billing handoff, rating read-model, Contracts input, Policy gate, OSS +provisioning, Payments signals ([`design/09-consumer-contracts.md`](./design/09-consumer-contracts.md)). + +### 3.3 API Contracts + +_TBD (skeleton)._ The **control-plane operations** contract +(`cpt-cf-bss-subscriptions-interface-control-plane`, §9.1) and the external integration contracts +(`-contract-billing-handoff`, `-contract-rating-read-model`, `-contract-contracts-input`, +`-contract-policy-gate`, `-contract-oss-provisioning`, `-contract-payments-signals`, §9.2) are owned +by `09-consumer-contracts` and PRD §9; the point-of-use entitlement **check** read contract is owned +by `05-entitlements`. Concrete REST mappings, headers, OpenAPI, event field matrices, and error +taxonomies are owned by the slice designs, not this index (§6 content boundary). + +### 3.4 Internal Dependencies + +- **`toolkit-db`** — transactional persistence for the aggregate + revision history, the projected entitlement-check read model, the audit store, and the event outbox. +- **Coordination lease library** — singleton coordination for background work (renewal job, notice dispatch, scheduled-intent execution, recurring-generation cut). + +### 3.5 External Dependencies + +Integration boundaries, not components owned here (PRD §3.2 / §13): + +- **Pricing (Product Catalog)** — published `Plan`/`Price`/`PriceWindow` linkage, plan-change classification, phase → grant-set map, trial offers, sellability gate (SUB-P*). +- **Rating (evaluation core + pipeline)** — consumes composition read models + `(changeEffectiveAt, changeMode)`; owns proration math + usage slicing; shared ordering key (SUB-R*). +- **Catalog registry (Product & SKU)** — published `skuId`/`PlanTier`/`CatalogVersion`, `catalogSubscriptionProductKey` for the overlap key; upstream PR #4177, not vendored (SUB-G*). +- **Contracts & Agreements** — renewal/grace/regional templates, ramps, commitment pools, booking date, `PriceOverride` windows (SUB-C*). +- **Billing & Invoicing** — recurring ingestion, posting, adjustments, floor/cap + rounding, dunning, `collectionPaused` artifact treatment (SUB-B*). +- **Policy Engine / OSS Provisioning** — fail-closed gate; provisioning confirmation; entitlement enforcement execution (SUB-E*). +- **Payments (PSP)** — pre-check + retry-exhaustion signals; authorization at renewal/conversion (SUB-F1). **Notifications/Comms** — notice/win-back delivery (SUB-F2). +- **AMS / OSS (tenant identity)** — tenant identity + topology, delegation-proof backbone. + +### 3.6 Interactions & Sequences + +Per-flow sequences are specified in the corresponding slice documents; the load-bearing ones: + +#### Policy-gated transition + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-seq-policy-gated-transition` + +Validate + create `TransitionRequest` → Policy pre-check (deny ⇒ no state change, `reasonCodes`) → +OSS provision/deprovision confirm where required → commit + increment `version` + issue/revoke +entitlements → emit ordered lifecycle events; idempotent on `(subscriptionId, idempotencyKey)` +([`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md)). + +#### Plan/quantity change boundary + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-seq-change-boundary` + +Set `changeEffectiveAt`/`changeMode` (up/down asymmetry) → close prior / open new `PlanLink` interval +or schedule a boundary → emit `SubscriptionPlanChanged` carrying the boundary inputs → rating slices +usage + prorates at the same instant; no posted-invoice mutation +([`design/03-plan-changes.md`](./design/03-plan-changes.md)). + +#### Renewal + grace ladder + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-seq-renewal-grace` + +Renewal job at term end → payment pre-check → success: extend term + fresh snapshot refs; failure: +enter grace (default 7 days, paused next-term recurring), notices, hybrid exit → `suspended`/ +`cancelled` per Contract ladder; keyed to prevent double term extension +([`design/04-suspension-renewal-grace.md`](./design/04-suspension-renewal-grace.md)). + +#### Point-of-use entitlement check + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-seq-entitlement-check` + +A tenant-isolated read of the projected check surface (feature flag, quota remaining, limit state) at +p95 < 100ms; OSS enforces the decision; updates propagate to the surface within the §7.1 baseline +([`design/05-entitlements.md`](./design/05-entitlements.md)). + +#### Trial conversion + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-seq-trial-conversion` + +Advance the phase boundary (`convertsToPhaseId` at term, or `convertTrial` to `now`) → authorize +payment where required → re-issue entitlements per the target phase's grant set with no access gap → +emit the composition-changing event; idempotent (zero missed / zero double conversions) +([`design/06-trials.md`](./design/06-trials.md)). + +### 3.7 Database schemas & tables + +The canonical schema — the `subscription` aggregate + append-only revision history, `plan_link` / +`add_on` interval tables, `transition_request` (with idempotency key) + `approval`, the +`entitlement` state + the projected **entitlement-check read model**, scheduled-intent and +renewal-evaluation tables, the event outbox, and the append-only audit store — is owned by the +Foundation and specified normatively in +[`design/01-foundation-lifecycle.md`](./design/01-foundation-lifecycle.md) §3.7. Slice-specific +tables (grace evaluation, trial phase state, transfer approvals) are introduced by their slice +documents. All stores are tenant-partitioned; effective dating is UTC. + +### 3.8 Deployment Topology + +The subscriptions gear runs as a stateful control-plane + read-model service over a shared +`toolkit-db` backend, partitioned by the pinned `orderingTenantId` (SUB-D-06 — stable across +transfers, so no aggregate row set migrates partitions). Background work (renewal job, notice +dispatch, scheduled-intent execution, recurring-generation cut) is coordinated as a singleton via the +coordination lease library. The entitlement-check read path is served from the projected read model +for the p95 < 100ms target. Deployment specifics are platform-standard for a BSS gear. + +## 4. Additional context + +**Cross-cutting normatives** (frozen resolutions from [`SEAMS.md`](./SEAMS.md), binding every slice): + +- **WHEN/MATH split (SUB-R1):** Subscriptions owns `changeEffectiveAt`/`changeMode`; rating owns proration math + usage slicing; shared ordering key `(orderingTenantId, subscriptionId)` — the ordering tenant is **pinned at creation** and immutable across transfers (SUB-D-06). +- **Recurring split (SUB-D-07, SUB-R6):** Subscriptions cuts the money-free recurring **period fact** (anchor, pauses, intents, the idempotency key); rating **prices** it; Billing posts. No monetary column in this gear. +- **Snapshot segment (SUB-R2):** Subscriptions writes only the `(currency, region)` segment of `pricingSnapshotRef` at activation; seat provenance and the activation date-trio are **not** snapshot segments — they ride events/read-models. +- **Adopt the catalog contracts (SUB-P1/P2/P3):** plan-change classification, phase → grant-set map, and trial offer are pricing/catalog-authored and adopted verbatim; Subscriptions authors nothing catalog-side. +- **Contracts obligation (SUB-C1):** renewal/grace/regional templates are Contracts SoR; until the upstream Contracts PRD authors them, the platform defaults govern (7-day grace, 30/14/7/1 notices). +- **Billing invariants (SUB-B1):** recurring idempotency `(subscriptionId, billing period)`, posted-invoice immutability, `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef` traceability; Billing exposes the `billedThroughAt` watermark for the backdating guard (SUB-B6). +- **Fail-closed gate + enforcement split (SUB-E1/E3):** Policy gates resource-affecting transitions fail-closed; Subscriptions serves the entitlement decision state, OSS enforces; the check **read** surface follows the bounded-staleness degraded mode (SUB-D-10), transitions never do. +- **Closed manifest enum (SUB-D-03/05/11):** trials, billing-only pause, and scheduled intents are attributes/postures/pending-intents, never new statuses; `draft → cancelled` (void) completes the edge set so drafts are exitable. +- **One commit path, complete inventories (SUB-D-08/09):** every FR-mandated mutation has a `TransitionRequest.type`; every FR-mandated auditable event has a name in the slice-08 registry. + +**Autonomous decisions** — [`DECISIONS.md`](./DECISIONS.md) SUB-D-01…12 (flagged for veto): +scheduled intents, `updateQuantity`, `collectionPaused`, ramps, activation date-trio, pinned +ordering tenant, recurring split, mutation-type completion, event-inventory naming, check-surface +staleness budget, draft void, pause × renewal interplay. + +**Open cross-team items (Design-lock inputs)** — the highest-risk is the **Contracts** renewal/grace +SoR (SUB-C1, upstream unauthored); then the rating read-model **field mirror** (SUB-R1, downgraded +from ALIGNED — seat quantity @ `t`, `priceEligibility` inputs), the recurring-pricing counterpart + +joint fixture (SUB-R6), the brand-context source discrepancy (SUB-R5), the rating seat-boundary +transport (SUB-R3), the OSS enforcement + quota mid-request contract and staleness-budget default +(SUB-E3, SUB-D-10), the Billing pause/prepaid lines + `billedThroughAt` watermark (SUB-B2/B4/B6), +the registry `catalogSubscriptionProductKey` shape (SUB-G1, PR #4177), and the +acceptance-confirmation flow (SUB-C4). Manifest alignment for the new `TransitionRequest.type` +values + intent envelope (SUB-N1) is tracked in [`PRD.md`](./PRD.md) §15, not a design blocker. + +## 5. Traceability + +- **PRD**: [`PRD.md`](./PRD.md) (§6 functional; §7 NFR; §9 contracts; §12 acceptance criteria; §15 open questions). +- **Cross-gear contract**: [`SEAMS.md`](./SEAMS.md) (seam register organised by neighbour + decision log). +- **Decisions**: [`DECISIONS.md`](./DECISIONS.md) (SUB-D-01…12, flagged for veto). +- **Vendor gap analysis**: [`STRIPE-ZUORA-GAP-ANALYSIS.md`](./STRIPE-ZUORA-GAP-ANALYSIS.md) (G-1…G-6). +- **ADRs**: [`ADR/`](./ADR/) — [`0001`](./ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md) (manifest-closed status machine), [`0002`](./ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md) (WHEN/MATH split), [`0003`](./ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md) (scheduled intents on the aggregate) — all `status: proposed`, flagged for veto. +- **Slices**: [`design/`](./design/) (per-slice designs) and [`design/README.md`](./design/README.md). diff --git a/gears/bss/subscriptions/docs/PRD.md b/gears/bss/subscriptions/docs/PRD.md new file mode 100644 index 000000000..2f7fae5d0 --- /dev/null +++ b/gears/bss/subscriptions/docs/PRD.md @@ -0,0 +1,1445 @@ +--- +refs: + - bss/manifest/vz-arch-manifest-bss-only.md + - bss/prd/PRD-billing-ledger-balances-202604041200 + - bss/prd/PRD-billing-module-202601120119 + - bss/prd/PRD-contracts-agreements-202601120119 + - bss/prd/PRD-plan-price-modeling-202605281200 + - bss/prd/PRD-product-catalog-marketplace-202601120119 + - bss/prd/PRD-product-sku-management-202606101924 + - bss/prd/PRD-rating-engine-202604031200 + - bss/prd/PRD-subscriptions-entitlements-202601120119 + - bss/prd/PRD-tariffs-pricing-logic-202604011200 +--- + +# PRD — Subscriptions — End-to-End Lifecycle (Multi-Tenant Revenue Object) + + + + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Architecture Alignment](#2-architecture-alignment) + - [2.1 Terminology and Naming](#21-terminology-and-naming) + - [2.2 Predecessor PRDs and Scope Migration](#22-predecessor-prds-and-scope-migration) +- [3. Actors](#3-actors) + - [3.1 Human Actors](#31-human-actors) + - [3.2 System Actors](#32-system-actors) +- [4. Operational Concept & Environment](#4-operational-concept--environment) + - [4.1 Module-Specific Environment Constraints](#41-module-specific-environment-constraints) +- [5. Scope](#5-scope) + - [5.1 In Scope](#51-in-scope) + - [5.2 Out of Scope](#52-out-of-scope) +- [6. Functional Requirements](#6-functional-requirements) + - [6.1 Lifecycle State Machine](#61-lifecycle-state-machine) + - [6.2 Versioning and Effective-Dated Composition](#62-versioning-and-effective-dated-composition) + - [6.3 Plan Changes (Upgrade / Downgrade)](#63-plan-changes-upgrade--downgrade) + - [6.4 Suspension and Reactivation](#64-suspension-and-reactivation) + - [6.5 Renewal and Grace](#65-renewal-and-grace) + - [6.6 Multi-Tenant Ownership](#66-multi-tenant-ownership) + - [6.7 Event Model](#67-event-model) + - [6.8 Billing Alignment](#68-billing-alignment) + - [6.9 Entitlement Lifecycle (Issue, Revoke, Point-of-Use)](#69-entitlement-lifecycle-issue-revoke-point-of-use) + - [6.10 Trial Runtime and Conversion](#610-trial-runtime-and-conversion) +- [7. Non-Functional Requirements](#7-non-functional-requirements) + - [7.1 NFR Inclusions](#71-nfr-inclusions) + - [7.2 NFR Exclusions](#72-nfr-exclusions) +- [8. Five Quality Vectors Analysis](#8-five-quality-vectors-analysis) +- [9. Public Library Interfaces](#9-public-library-interfaces) + - [9.1 Public API Surface](#91-public-api-surface) + - [9.2 External Integration Contracts](#92-external-integration-contracts) +- [10. Use Cases](#10-use-cases) +- [11. User Interaction and Design](#11-user-interaction-and-design) +- [12. Acceptance Criteria](#12-acceptance-criteria) + - [Lifecycle and guards](#lifecycle-and-guards) + - [Composition and billing](#composition-and-billing) + - [Renewal and activation eligibility](#renewal-and-activation-eligibility) + - [Versioning, multi-tenant, and events](#versioning-multi-tenant-and-events) + - [Entitlements and trials](#entitlements-and-trials) + - [Scheduled intents, quantity, pause, activation instants](#scheduled-intents-quantity-pause-activation-instants) + - [Ordering pin, recurring split, void, pause interplay](#ordering-pin-recurring-split-void-pause-interplay) +- [13. Dependencies](#13-dependencies) +- [14. Assumptions](#14-assumptions) +- [15. Open Questions](#15-open-questions) +- [16. Risks](#16-risks) +- [17. Reference Materials](#17-reference-materials) + - [17.1 Reconciliation Framework (operational appendix)](#171-reconciliation-framework-operational-appendix) + + + +## 1. Overview + +### 1.1 Purpose + +**Subscriptions** is the BSS gear that owns the **subscription** as the **primary commercial aggregate** for recurring revenue: a versioned, auditable **lifecycle state machine** with **effective-dated composition** (`PlanLink`, `AddOn`) that **aligns** Rating (usage + rated charges) and Billing (recurring line items, tax, GL, ASC inputs) under **multi-tenant** ownership (`resourceTenantId`, `payerTenantId`, `sellerTenantId`). + +This gear owns the lifecycle **engine** — state machine, versioning and snapshots, renewal and failed-renewal/grace, events and ordering, multi-tenant delegation — **not** the catalog primitives it composes (`Plan`/`Price`/`PriceWindow` are the Pricing gear's), **not** the proration/evaluation math (the Rating gear's), and **not** posting or invoice immutability enforcement (Billing's). Since the 2026-07-15 consolidation it is also the normative home for the **entitlement lifecycle** — issue/revoke, the point-of-use check contract, quotas and limits (§6.9) — and for **trial runtime & conversion** (§6.10), absorbed from the predecessor Subscriptions & Entitlements PRD (§2.2). + +### 1.2 Background / Problem Statement + +The predecessor `PRD-subscriptions-entitlements-202601120119` describes the **Subscriptions + Entitlements module** end-to-end (PRD-0001 fit, trials, **enforcement at point of use**, SLAs). This PRD is the **manifest-first, production-ready lifecycle specification** for the subscription **commercial aggregate**: manifest §4.3 **state machine**, **versioning** and snapshots, **Billing/Rating** alignment, **renewal** and **failed-renewal/grace**, **events** and ordering, **multi-tenant delegation** — so the lifecycle **engine** has a single normative home. + +Without a single normative lifecycle home, transitions, renewal ladders, and proration triggers get re-invented per consumer, posted invoices get silently mutated, and partner-facing billing becomes unexplainable. This PRD fixes the state machine, the composition/versioning discipline, the renewal/grace ladder, the event inventory and ordering, and the Billing alignment invariants so Design implements — not invents — those rules. + +The predecessor additionally carried the **entitlement framework** (feature flags, usage/resource quotas, soft/hard limits, the p95 < 100ms check API), **trial management**, renewal notices, and the module SLA table. On 2026-07-15 this repository **consolidated both documents into this single PRD** — the "until re-homed" split was a sequencing artifact of the upstream rewrite, not an architectural boundary, and it cut through the middle of one domain concept (Entitlement). §2.2 records what each predecessor section became and which concerns were superseded to sibling gears; upstream (vhp-architecture) still keeps two documents. + +### 1.3 Goals (Business Outcomes) + +**Engineering outcomes** + +- **Deterministic** transitions with explicit **guards**, **Policy Engine** pre-checks, and **OSS provisioning** confirmation where required by manifest §4.3. +- **Subscription versioning** so historical bill runs reproduce **without** re-interpreting mutable catalog state (snapshot refs per the §4.1 contract). +- **Plan changes** (upgrade/downgrade) with **proration triggers** coordinated between Subscriptions and Billing (not silent invoice mutation). +- **Renewal** semantics aligned with **Contract** `Renewal.autoRenew` and term windows (§4.6), with explicit handling of **failed renewal** (grace, suspension policy). +- **CFO-grade** audit: immutable lineage of state changes, entitlement issuance/revocation, and financial artifact linkage (ASC **inputs** via tags/snapshots; recognition engines out of scope). + +**Partner / operator outcomes** (Alternative Cloud Providers and their operators) + +- **Predictable billing for partners**: plan changes materialize **proration** as **new billable or adjusting artifacts**, not silent edits to posted invoices — partners can **explain charges** to end customers with a clear paper trail. +- **Transparent failure handling**: **failed renewal** and grace paths emit **auditable** events so partners have **time and signal** to intervene before service is degraded or suspended per policy. +- **Delegation-safe operations**: cross-tenant admin actions require **auditable delegation proofs**, reducing risk of **unauthorized** changes across reseller hierarchies. + +**SLA baseline (actionable):** module SLAs from the predecessor (**p95 < 1s** lifecycle synchronous commit class, **p95 < 100ms** subscription-backed entitlement check, **daily by 00:00** recurring generation cut, **100%** proration/plan-change monetary accuracy vs policy) are **carried forward** as §7.1 NFR baselines and **MUST be reconciled** with the program NFR workshop when published (§15). + +### 1.4 Glossary + +| **Term** | **Definition** | +|----------|------------------| +| **AddOn** | Optional add-on product attached to a subscription with an effective window (`start`/`end` or equivalent); composes with **`PlanLink`** for commercial/rating context (manifest §4.3). | +| **Billing anchor** | UTC instant or calendar rule fixing cycle boundaries (`billingAnchor` on Subscription per manifest data model); drives recurring `BillableItem` period alignment. | +| **Cancellation policy** | Rules governing how a cancel takes effect — immediate, end-of-term, or at a date — and refund/credit eligibility. Modeled as **scheduled lifecycle intents** (§6.1, SUB-D-01); credits materialize only as Billing artifacts. | +| **Commercial aggregate** | Domain aggregate rooted at `subscriptionId`; ordering key for CloudEvents per manifest §4.2/§4.3. | +| **Committed usage** | Contractual minimum usage/spend over a term, tracked for true-up. Commitment pools are **Contracts SoR**, evaluated with true-up by the rating gear (T-D-14); this gear keeps subscription-side hooks only (§2.2). | +| **Entitlement** | Authorization defining what resources, features, or usage limits a customer can access based on the active subscription. Authored as the plan's grant set (pricing gear, incl. the per-phase map), **issued/revoked and accounted here** (§6.9); enforced at point of use by OSS against this gear's check contract. | +| **Evaluated fields** | Subscription-stored attributes (e.g. **`graceEndsAt`**, pause flags, ladder variant) computed at **renewal evaluation** time from **Contract** / **`Renewal`** terms for audit, idempotent renewal jobs, and replay. | +| **Evergreen subscription** | Indefinite-term subscription continuing until explicitly cancelled (no fixed end date); terms and notice behavior live on Contract §4.6. | +| **Feature flag** | Boolean entitlement controlling access to a product feature (enabled/disabled per subscription). | +| **Fixed-term subscription** | Subscription with a defined start/end; may auto-renew or expire at term end (Contract terms; self-service term metadata on the Plan is deferred — pricing §17.8). | +| **Grace period** | Window after a **failed renewal** attempt (pre-check or aligned billing failure) during which the subscription may stay **`active`** while retries/dunning run; duration, billing posture, and exit triggers in §6.5. | +| **Hard limit** | Usage threshold blocking further usage until a quota increase or a new billing cycle; enforced by OSS on this gear's quota state (§6.9). | +| **idempotencyKey** | Client-supplied identifier on mutating requests; Subscriptions MUST treat duplicate `(subscriptionId, idempotencyKey)` as the same logical operation so exactly **one** durable effect results (manifest §4.3). | +| **PlanLink** | Effective-dated link between a subscription and a **catalog plan** (`planId` with `effectiveFrom`/`effectiveTo`); defines which plan applies for rating/billing over each interval (manifest §4.3). The referenced plan/price primitives are authored in the **Pricing gear** ([pricing PRD](../../pricing/docs/PRD.md)). | +| **Plan phase** | Time-bounded segment of a subscription plan (e.g. **trial**, intro, evergreen) with its own price schedule. **Phase structure is Subscriptions SoR** (this PRD / composition); evaluation resolves the **active phase at `t`** — see the **Rating gear** ([rating PRD](../../rating/docs/PRD.md) §17.1 step 1). Trials are modeled as a phase, **not** a `Subscription.status` (§6.1). | +| **PlanTier** | Commercial **tier** implied by the subscribed SKU/plan (e.g. edition/steps); MUST be derivable for any charge instant and remain consistent with **Policy**-gated composition changes (manifest §4.3). The `PlanTier` **taxonomy** is owned by the Catalog registry — the **products** gear (`gears/bss/products/docs/PRD.md`, vendored 2026-07-16). | +| **PriceWindow** | Catalog price interval (`planId`/`priceId`, `effectiveFrom`, `effectiveTo`) per BSS manifest §4.1; promotional/trial offers are expressed as time-bounded **PriceWindow**s on the relevant plan. Linkage/authoring and window scheduling/activation in the **Pricing gear** ([pricing PRD](../../pricing/docs/PRD.md)). | +| **prorationBasis** | Day-count convention (`calendar_days_actual`, `calendar_days_30`, `by_second`, `whole_unit`) applied to **all** mid-period proration of the recurring component; configured on the plan/price policy and frozen in `pricingSnapshotRef`. **Owned by the Rating gear** ([rating PRD](../../rating/docs/PRD.md)); Subscriptions only sets the change boundary/mode. | +| **Regional template** | Seller-defined **contract template** with jurisdiction-specific commercial defaults (e.g. grace duration within **Legal** bounds); authoritative in **Contracts** (`PRD-contracts-agreements-202601120119`, upstream). | +| **Renewal notice** | Notification sent before auto-renewal at configurable intervals (default 30/14/7/1 days) for customer awareness; triggered here, delivered via Notifications (§6.5). | +| **Resource-affecting transition** | Any transition that changes entitlements, provisioned resources, or quota-bearing bindings; MUST pass the Policy Engine gate before commit (manifest §4.3, §6). | +| **Resource quota** | Entitlement limiting resource provisioning (e.g. max 10 VMs, max 5 TB total storage). | +| **Soft limit** | Usage threshold triggering warnings while allowing continued usage; overage MAY bill per plan policy (§6.9). | +| **Subscription pause** | Temporary pause preserving subscription state with no charges during the pause window — distinct from suspension (service-affecting). Modeled as the **`collectionPaused`** posture on `active` (§6.4, SUB-D-03). | +| **Subscription revision** | Monotonic `version` (and/or revision record) capturing **effective-dated** composition and commercial snapshot pointers after a committed transition. | +| **Trial period** | Time-limited free or reduced-cost period modeled as the leading plan phase (§6.1); runtime, conversion, expiry, and extension in §6.10. | +| **Usage quota** | Numeric entitlement defining maximum allowed usage (e.g. 100 GB storage, 1000 API calls/month); tracked against usage aggregates (§6.9). | + +## 2. Architecture Alignment + +| **Field** | **Value** | +|-----------|----------| +| **Applicable Manifest(s)** | BSS | +| **Relevant Chapters** | §4.3 Subscriptions and Entitlements; §4.1 Product and Service Catalog (SKU/Plan/snapshots); §4.2 Rating and Charging (aggregate ordering, tariff evaluation, Usage→charge); §4.4 Billing and Invoicing (recurring `BillableItem`, immutability); §4.6 Contracts and Agreements (defaults, renewals, overrides); §2.1.3 Multi-tenant semantics; §6 BSS↔OSS Interlocks (Policy Engine gate) | + +> **Normative baseline**: this PRD MUST remain consistent with the BSS manifest: Subscriptions/Entitlements are **SoR** in BSS; **OSS** provisions resources; **Policy Engine** gates **resource-affecting** transitions fail-closed; transitions are **idempotent** on `(subscriptionId, idempotencyKey)`; event ordering is **within `(tenantId, aggregateId)`** with **`aggregateId = subscriptionId`** for subscription streams; recurring fees are **`BillableItem(kind=recurring)`** idempotent per **`(subscriptionId, billing period)`**; published financial documents remain **immutable** with corrections via **adjustments/credit/debit notes** (Billing). Detailed API/event schemas belong in **Design** artifacts. + +**Manifest conformance.** This PRD matches the manifest for SoR boundaries and Policy-gated resource-affecting changes (§2.1.2, §4.3, §6); subscription **status** values and transitions, including **resume**; entities `PlanLink`, `AddOn`, `TransitionRequest`, `Entitlement`, and `version`; lifecycle producers/consumers in §4.3, including **`SubscriptionPlanChanged`**; recurring **`BillableItem`** idempotency per **`(subscriptionId, billing period)`** and posted-invoice immutability (§4.4); and contract renewal semantics from §4.6 (`Renewal`, `autoRenew`, terms, snapshots). + +**Catalog/Rating decomposition (cross-gear ownership).** Manifest §4.1/§4.2 are decomposed into dedicated PRDs that this PRD **consumes by reference** and MUST NOT re-author. In this repository two of them are vendored as sibling gears (see §2.1 for the mapping): + +| **Concern** | **Authoritative owner** | **This PRD's relationship** | +|-------------|-------------------------|------------------------------| +| Product, SKU, Category, Attribute, `PlanTier` **taxonomy**, `CatalogVersion`, publish | Catalog registry — the **products** gear (`gears/bss/products/docs/PRD.md`, vendored 2026-07-16) | Reads **published** SKUs / `CatalogVersion`; `PlanLink` / overlap key bind to published catalog keys | +| `Plan`, `Price`, `PriceWindow` linkage, bundle/add-on rules, billing descriptors | **Pricing gear** — [pricing PRD](../../pricing/docs/PRD.md) (§4.1) | `PlanLink` / `AddOn` reference these primitives; trial offers are catalog plans/price windows | +| Tariff **evaluation semantics**: graduated/volume math, override hierarchy, coupons, FX, **proration math** (`prorationBasis`) | **Rating gear**, evaluation core — [rating PRD](../../rating/docs/PRD.md) (§4.2) | Subscriptions owns the plan-change **WHEN** + `changeMode`; the evaluation core consumes `(changeEffectiveAt, changeMode)` and owns the math | +| Usage → `RatedCharge` / `BillableItem` orchestration, dedup, partition ordering | **Rating gear**, operational pipeline — [rating PRD](../../rating/docs/PRD.md) (§4.2) | Rating reads subscription composition + `PlanTier` @ `t` from this PRD's read models | + +**Manifest silence and deferrals** (MUST be resolved in manifest, Design, or explicit product rules — this PRD MUST NOT invent conflicting enums or global cardinality): **trials** are handled per §6.1 (attribute/composition on manifest statuses, not a `trial` status value), including the **commercial pattern** there. **Overlapping active subscriptions** use the **default cardinality** in §6.3 (the manifest does not fix global cardinality). **ASC 606** operational detail is outside the manifest; this PRD only states subscription-level traceability and snapshot hooks for Finance/Billing. Design documentation MUST close trial attribute/event naming, overlap **dimension** binding, and **Payments/Billing integration details** (PSP webhooks, dunning handoff payloads) consistent with the **grace ladder** in §6.5. + +### 2.1 Terminology and Naming + +| **Name** | **Usage** | +|----------|-----------| +| **Subscriptions** | Canonical name of this gear and its domain (manifest §4.3): the subscription commercial aggregate, its lifecycle engine, composition, renewal, events, and multi-tenant ownership. | +| **Pricing (Product Catalog)** | The sibling gear vendoring upstream `PRD-plan-price-modeling-202605281200` — SoR for `Plan`/`Price`/`PriceWindow`/`PriceOverlay`/`CatalogVersion` that `PlanLink`/`AddOn` reference ([pricing PRD](../../pricing/docs/PRD.md)). | +| **Rating** | The sibling gear vendoring upstream `PRD-tariffs-pricing-logic-202604011200` + `PRD-rating-engine-202604031200`, **consolidated into one gear** per rating [ADR-0002](../../rating/docs/ADR/0002-cpt-cf-bss-rating-adr-rating-gear-consolidation.md): the pure evaluation core (`rating-core`, successor of "PLAL"/"Tariffs") plus the operational pipeline. Upstream text referring to "Tariffs" and "Rating" as two PRDs reads onto this one gear. | +| **Catalog registry** | The **products** gear — `gears/bss/products/docs/PRD.md` (Product/SKU/Category/Attribute/`PlanTier` taxonomy/`CatalogVersion`); vendored 2026-07-16 from upstream PR #4177 (provenance in the doc). | +| **Contracts / Billing / Payments / Promotions** | Upstream domains without vendored gears in this repository; referenced by their upstream PRD names (§17). | + +### 2.2 Predecessor PRDs and Scope Migration + +`PRD-subscriptions-entitlements-202601120119` describes the **Subscriptions + Entitlements** module in breadth (PRD-0001 mapping, SLAs, recurring charges, trials, and entitlement enforcement at point of use). **This PRD does not archive or fully replace that document**; it **specializes** the **BSS-manifest-aligned commercial lifecycle** for the subscription aggregate: §4.3 **status** model and operations (including **resume**), **`TransitionRequest`** / **idempotency** / **ordering**, Policy Engine and OSS interlocks, effective-dated **`PlanLink`** / **`AddOn`** and subscription **versioning**, recurring **`BillableItem`** rules aligned to §4.4, Contract-linked **renewal** and **failed-renewal** boundaries, and multi-tenant **ownership** / **delegation**. Work and acceptance criteria for those topics SHOULD trace **here** and to **Design**, rather than duplicating parallel normative lifecycle text in the predecessor. + +**Consolidation (2026-07-15, this repository).** The predecessor is **absorbed into this PRD**; in this repository it is superseded in full. Section map: + +| Predecessor section | Fate in this PRD | +|---|---| +| Subscription lifecycle / recurring / auto-renewal / proration ACs | Superseded by §6.1–§6.8 and §12 (manifest-first wording wins; predecessor's `pending` state reads as `draft`) | +| Entitlement Management framework (flags, quotas, soft/hard limits, p95 < 100ms check API) | **Absorbed** → §6.9 (state + check contract here; enforcement execution = OSS) | +| Trial Management (creation, conversion, expiry, extension) | **Absorbed** → §6.10 (runtime on the phase machinery; status rules stay §6.1) | +| Renewal notices (30/14/7/1), opt-out | **Absorbed** → §6.5 | +| Module SLA table / PRD-0001 SLAs | **Absorbed** → §7.1 (+ operational baselines) | +| UX consoles (admin, partner portal, self-service, entitlement dashboard) | **Absorbed** → §10/§11 | +| Reconciliation framework | **Absorbed** → §17.1 appendix | +| Committed usage tracking / true-up | **Superseded to owners**: commitment pools = Contracts SoR, true-up = rating (T-D-14); this gear keeps subscription-side hooks only | +| Usage credit pools / prepaid | **Superseded to owners**: prepaid credit grant = pricing D-43 (definition), balance/drawdown = Billing/Rating (GA-gated) | +| Renewal price lock / grandfathering | **Superseded to owner**: pricing `priceEligibility` + `cohort` generations (pricing ADR-0002) | +| Subscription bundling economics | **Superseded to owner**: pricing bundles (rev-share, itemization); here only parent/child lifecycle sync (§5.1, `p3`) | +| Plan migration tools | **Superseded to owner**: pricing lifecycle slice authors migration; Subscriptions executes `PlanMigrationScheduled` | +| Market Intelligence / competitor matrix | Superseded by [STRIPE-ZUORA-GAP-ANALYSIS.md](./STRIPE-ZUORA-GAP-ANALYSIS.md) (fresher; Chargebee/CloudBlue learnings folded there) | +| Module integration mermaid/sequence diagrams | Superseded by §4 diagrams; sequence depth → `DESIGN-subscriptions-*` | + +**Canonical home**: this PRD (in `gears-rust`) is the single source of truth for the subscriptions gear; it absorbs the predecessor `PRD-subscriptions-entitlements` (section map above). The upstream vhp-architecture copies are **legacy provenance, not maintained** — there is no drift-tracking or back-port obligation. + +## 3. Actors + +### 3.1 Human Actors + +#### Partner Admin + +**ID**: `cpt-cf-bss-subscriptions-actor-partner-admin` + +**Role**: Operates customer subscriptions across the reseller hierarchy — suspend/resume/cancel, plan changes, renewal intervention — without engineering involvement. +**Needs**: Subscription admin console, `TransitionRequest` tracking, delegation-proof-backed cross-tenant actions, auditable outcomes. + +#### Customer (Self-Service) + +**ID**: `cpt-cf-bss-subscriptions-actor-customer` + +**Role**: Buys, changes, and cancels own subscriptions; previews commercial impact before confirming a change. +**Needs**: Plan-change wizard with proration preview, effective-timing choice (immediate / next-cycle / end-of-term), clear rejection reasons. + +#### Finance Analyst + +**ID**: `cpt-cf-bss-subscriptions-actor-finance-analyst` + +**Role**: Monitors renewals and dunning exposure; audits lifecycle-to-invoice lineage for compliance. +**Needs**: Failing-renewal filters, audit-trail export, snapshot-linked charge traceability. + +#### Product Manager (Entitlement Admin) + +**ID**: `cpt-cf-bss-subscriptions-actor-product-manager` + +**Role**: Configures entitlement templates on plans (via the catalog grant sets) and monitors quota usage, limits, and compliance across subscriptions. +**Needs**: Entitlement dashboard — templates, quota monitoring, flag management, compliance reports and export. + +#### Platform Operator + +**ID**: `cpt-cf-bss-subscriptions-actor-platform-operator` + +**Role**: Owns governed subscription lifecycles so revenue, entitlements, and invoices stay consistent across tenants and channels. +**Needs**: State-machine observability, DLQ/replay for failed transitions, approval workflow for high-risk transitions (transfer). + +### 3.2 System Actors + +#### Pricing (Product Catalog) + +**ID**: `cpt-cf-bss-subscriptions-actor-pricing` + +**Role**: SoR for `Plan`/`Price`/`PriceWindow` linkage, bundle/add-on rules, billing descriptors, and publish/sellability gates that `PlanLink`/`AddOn` resolve against; trial offers are catalog plans/price windows ([pricing PRD](../../pricing/docs/PRD.md)). + +#### Catalog Registry (Product & SKU) + +**ID**: `cpt-cf-bss-subscriptions-actor-catalog-registry` + +**Role**: SoR for published `skuId`, `PlanTier` taxonomy, `CatalogVersion`; the overlap key (`catalogSubscriptionProductKey`) binds to its published keys (the **products** gear, `gears/bss/products/docs/PRD.md`). + +#### Rating (Evaluation Core + Pipeline) + +**ID**: `cpt-cf-bss-subscriptions-actor-rating` + +**Role**: Reads subscription composition, effective `PlanLink`, `PlanTier`, and active **plan phase** at `t` via read models; consumes plan-change `(changeEffectiveAt, changeMode)` for proration math; slices usage at the same boundary; owns Usage → `RatedCharge` / `BillableItem` orchestration ([rating PRD](../../rating/docs/PRD.md)). + +#### Billing & Invoicing + +**ID**: `cpt-cf-bss-subscriptions-actor-billing` + +**Role**: Ingests recurring `BillableItem`s; aligns periods/proration artifacts; posts immutable invoices; executes adjustments/credit/debit notes; runs dunning with Payments. + +#### Contracts & Agreements + +**ID**: `cpt-cf-bss-subscriptions-actor-contracts` + +**Role**: SoR for signed terms, `Renewal` (`autoRenew`, term windows, notice), grace ladder and regional templates, `PriceOverride` windows; supplies defaults/snapshots via events and read models. + +#### Policy Engine + +**ID**: `cpt-cf-bss-subscriptions-actor-policy-engine` + +**Role**: Gates **resource-affecting** transitions fail-closed before commit; returns allow/deny + `reasonCodes` (manifest §6). + +#### OSS Provisioning + +**ID**: `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +**Role**: Executes provision/deprovision/pause work orders confirmed by events; BSS never mutates OSS resource topology directly (manifest §2.1.2). + +#### Payments (PSP) + +**ID**: `cpt-cf-bss-subscriptions-actor-payments` + +**Role**: Supplies payment pre-check and retry-exhaustion signals consumed by the renewal/grace ladder; payment capture and PSP behavior stay out of scope (§4.5). + +#### AMS / OSS (Tenant Identity) + +**ID**: `cpt-cf-bss-subscriptions-actor-ams` + +**Role**: SoR for tenant identity and topology (`resourceTenantId` references, account/OrgTier context); Subscriptions references, never invents, tenant topology. + +#### Analytics / DWH + +**ID**: `cpt-cf-bss-subscriptions-actor-analytics` + +**Role**: Consumes lifecycle facts. + +## 4. Operational Concept & Environment + +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ Common Core: IdP, API Gateway (Inbound), Events & Audit, Correlation IDs │ +└─────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ Policy Engine gate ┌──────────────────────────┐ +│ Contracts │ ──defaults/snapshots──▶ │ Subscriptions (SoR) │ +│ (SoR) §4.6 │ ContractSigned/... │ §4.3 │ +└─────────────────┘ │ State machine, PlanLink, │ + │ │ Entitlement issue/revoke │ + │ └───────────┬───────────────┘ + │ │ + │ ┌─────────────────────────────────┼──────────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Catalog §4.1 │ │ OSS Provision│ │ Rating §4.2 │ │ Billing §4.4 │ +│ sku/plan/ref │ │ (execute) │ │ usage+rules │ │ invoice post │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +**End-to-end lifecycle (value stream)** + +```text +ContractSigned ──▶ create(draft) ──▶ activate ──▶ [active ── suspended loop] ──▶ cancel ──▶ archived + │ │ │ + │ └──▶ PlanLink/AddOn updates ──┘ + │ └──▶ recurring period fact ──▶ Rating (price) ──▶ Billing ──▶ Invoice(posted) +OSS Usage ──▶ Rating ──▶ BillableItem(usage) ─────────────────────────────────────────────▶ Billing +``` + +**Policy-gated transition (condensed)** + +```text +Client ─▶ API GW ─▶ Subscriptions: validate + TransitionRequest + │ + ├─▶ Policy Engine: pre-check (resource-affecting) + │ └─ deny → reasonCode (fail-closed) + ├─▶ OSS: provision/deprovision (confirm) + └─▶ Entitlements issue/revoke → CloudEvents → Billing/Rating/Analytics +``` + +**Inbound (Subscriptions consumes)** + +- **AMS/OSS**: tenant identity, `resourceTenantId` topology references (read-only for the BSS SoR split). +- **Catalog registry**: published `skuId`, `PlanTier` taxonomy, `CatalogVersion` for eligibility. +- **Pricing gear**: published `planId`, `PriceWindow` linkage / price snapshot refs that `PlanLink` / `AddOn` resolve against. +- **Contracts**: signed terms, `Renewal`, `PriceOverride` windows (events + read models). +- **Policy Engine**: allow/deny + `reasonCodes` for resource-affecting transitions. + +**Outbound (Subscriptions produces)** + +- **OSS Provisioning**: work orders confirmed by events (manifest flows). +- **Billing**: `BillableItemCreated(kind=recurring)` — the money-free period fact with stable catalog refs + `pricingSnapshotRef`; the rating gear prices it before Billing posts (SUB-D-07, §6.8; per the §4.1–4.2 contract). +- **Rating** (indirect): composition, effective `PlanLink`, `PlanTier`, and active **plan phase** at `t` via read models; plan-change `(changeEffectiveAt, changeMode)` consumed for proration math; usage already keyed by `subscriptionId`. +- **Analytics/DWH**: lifecycle facts. + +### 4.1 Module-Specific Environment Constraints + +- All effective dating, anchors, and boundaries are **UTC**; events are **CloudEvents 1.0**, tenant-scoped, minimal PII. +- Every mutating request is **idempotent** on `(subscriptionId, idempotencyKey)`; consumers preserve ordering within `(tenantId, aggregateId = subscriptionId)`. +- **Resource-affecting** transitions never commit without a Policy Engine pre-check (fail-closed) and, where required, OSS provisioning confirmation. +- BSS MUST NOT mutate OSS resource topology; Subscriptions **requests** changes via Policy-gated workflows only. + +## 5. Scope + +### 5.1 In Scope + +| **Feature** | **Priority** | **Notes** | +|-------------|--------------|-----------| +| Lifecycle state machine (manifest-aligned states + transitions) | `p1` | §6.1; terminality rules | +| Subscription versioning & effective-dated `PlanLink` / `AddOn` | `p1` | Aligns Rating/Billing to the same SKU/Plan set over time (§6.2) | +| Plan change (upgrade/downgrade) with proration policy hooks | `p1` | Triggers Billing alignment; no posted-invoice mutation (§6.3) | +| Suspension & reactivation (Policy + OSS paths) | `p1` | Entitlement revoke/restore per §4.3 (§6.4, §6.9) | +| Entitlement **issue** / **revoke** from subscription transitions | `p1` | Driven by activate/suspend/resume/cancel and composition-changing transitions; **not** quota/flag/**exhaustion** at consumption (§6.9, §2.2) | +| Renewal (auto/manual) with Contract linkage | `p1` | Consumes `ContractRenewed` / renewal terms where applicable (§6.5) | +| Event model (manifest producers + correlation) | `p1` | CloudEvents 1.0; ordering invariant (§6.7) | +| Recurring `BillableItem` emission to Billing | `p1` | Idempotent `(subscriptionId, period)` (§6.8) | +| Multi-tenant ownership & delegation proofs | `p1` | AMS/OSS identity backbone by reference (§6.6) | +| Entitlement enforcement framework: check contract (p95 < 100ms), quotas, soft/hard limits | `p1` | §6.9; state + decision data here, OSS executes enforcement | +| Trial runtime & conversion (create, auto-convert, early `convertTrial`, expire, extension) | `p1` | §6.10; rides the pricing phase machinery (D-19/D-41) | +| Scheduled lifecycle intents (cancel at term end / at date; resume-at) | `p1` | §6.1 pending intents + renewal-job interaction (SUB-D-01) | +| Seat/quantity change transition (`updateQuantity`) | `p1` | §6.3 envelope + provenance for pricing D-18 seat counts (SUB-D-02) | +| API surface (control plane): create, read, update metadata, transitions, cancel | `p2` | Business verbs only in PRD; idempotency + optimistic concurrency (§9.1) | +| Backdated & overlapping subscription rules | `p2` | §6.3; AC 6/8 | +| Failed renewal / dunning handoff | `p2` | Subscriptions state + Billing/Payments boundaries (§6.5) | +| ASC 606 hooks (PO tags, SSP snapshot refs on recurring lines) | `p2` | Downstream Finance/Billing | +| Renewal notices (configurable, default 30/14/7/1) + opt-out | `p2` | §6.5; delivery via Notifications | +| Billing-only pause (`collectionPaused` on `active`) | `p2` | §6.4 posture (SUB-D-03); billing-cycle mechanics open in §15 | +| Bulk subscription operations (batch create/update/cancel, async) | `p2` | Partner mass operations; batching in Design | +| Operator UX / approvals for high-risk transitions | `p3` | Ownership transfer cross-ref manifest §4.11 | +| Bundled-subscription lifecycle sync (parent/child) | `p3` | Bundle economics = pricing gear; here only synchronized lifecycle | +| Subscription analytics events (MRR/ARR, churn signals) | `p3` | Facts for Analytics/DWH (§6.7) | + +### 5.2 Out of Scope + +- **Proto/OpenAPI schemas**, error code taxonomies, DB DDL — **Design**. +- **OSS resource topology** mutations by BSS — **forbidden** (manifest §2.1.2); Subscriptions **requests** changes via Policy-gated workflows. +- **Full revenue recognition** and **subledger journals** — Finance/Billing; this PRD supplies **subscription-level** traceability and snapshot refs. +- **Payment capture** and **PSP** behavior — manifest §4.5; the subscription MAY react to **payment failure** events if defined in Design. +- **`trial` as `Subscription.status`** and a dedicated **`trial` → `active` | `cancelled`** lifecycle in the §4.3 state machine — **out of scope** until the manifest enum is amended; until then trials are **attributes/composition** only (§6.1). +- **Notification delivery channels and campaign content** (renewal notices, trial expiry, win-back messaging) — Notifications/Comms; this PRD fixes the triggers and intervals only (§6.5, §6.10). +- **Enforcement execution at the point of use** — OSS enforces (allow/block/degrade) against this gear's check contract and quota state (§6.9); the enforcement action itself and graceful-degradation behavior mid-request are OSS/Design concerns (§15). +- **UI implementation** of the consoles/portals in §11 — Presentation layer; this PRD fixes the operations they invoke. + +## 6. Functional Requirements + +> **Content boundary**: FRs define WHAT the lifecycle engine must guarantee, not data models or APIs. Concrete schemas, event attribute matrices, REST mappings, timers, and error taxonomies are owned by the corresponding DESIGN (`DESIGN-subscriptions-*/`). Proration **math** and tariff evaluation are owned by the Rating gear; posting and invoice immutability enforcement by Billing. + +### 6.1 Lifecycle State Machine + +#### Status enum and terminality + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-status-enum` + +Manifest `Subscription.status` is **`draft` | `active` | `suspended` | `cancelled` | `archived`**. The allowed path is **Draft → Active → Suspended → Cancelled → Archived**; **terminal states are immutable** except **archival** progression (`cancelled` → `archived`; no commercial rebirth without a new subscription). **Resume** (`suspended` → `active`) is explicitly supported and MUST be modeled as the reverse edge (the manifest lists operations, not every arrow in one line). **Void** (`draft` → `cancelled`) is likewise supported so abandoned drafts are exitable through the normal commit path — it is **not resource-affecting** (nothing is provisioned yet, no OSS leg) and accepts `cancelMode = immediate` only (SUB-D-11; an optional draft-retention TTL that submits the void is a Product knob, §15). + +```text + ┌──────────┐ + │ draft │ + └──┬────┬──┘ + │ │ cancel (void — SUB-D-11) + activate │ └─────────────────────────────────────────┐ + (Policy+OSS+entitlements) │ + ▼ │ + ┌──────────┐ suspend ┌───────────┐ │ + │ active │───────────────────▶│ suspended │ │ + │ │◀───────────────────│ │ │ + └────┬─────┘ resume (Policy) └─────┬─────┘ │ + │ cancel │ cancel │ + │ (Policy+OSS deprovision) │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────────┐ + │ cancelled │ + └──────────────────────────┬──────────────────────────┘ + │ archive (retention / legal) + ▼ + ┌──────────┐ + │ archived │ (terminal) + └──────────┘ +``` + +**Rationale**: A closed, manifest-aligned enum with explicit terminality is what makes lifecycle audit and replay possible. + +**Actors**: `cpt-cf-bss-subscriptions-actor-platform-operator` + +#### Normative transitions and guards + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-transitions-guards` + +| **From** | **To** | **Trigger (verb)** | **Guard (summary)** | +|----------|--------|--------------------|----------------------| +| draft | active | activate | Policy allow + OSS provision confirm + entitlements issued (manifest §4.3) | +| draft | cancelled | cancel (void) | Not resource-affecting (nothing provisioned); `cancelMode = immediate` only; audited (SUB-D-11) | +| active | suspended | suspend | Policy + coordinate deprovision/pause per product policy | +| suspended | active | resume | Policy allow; where the suspension was grace-driven, the blocking payment failure MUST be resolved first (§6.5) | +| active | cancelled | cancel | Policy + OSS deprovision + entitlement revocation | +| suspended | cancelled | cancel | Same as cancel from non-active where permitted | +| cancelled | archived | archive | Retention/governance; no commercial rebirth without a new subscription | + +Every **resource-affecting** transition MUST pass the Policy Engine pre-check before commit; on deny the subscription state MUST NOT change (AC 1). + +**Rationale**: Guards are the contract that keeps entitlements, provisioned resources, and commercial state consistent. + +**Actors**: `cpt-cf-bss-subscriptions-actor-policy-engine`, `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +#### Trials are not a status + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-trials-not-a-status` + +A commercial **trial** is **not** a `Subscription.status` value. Manifest §4.3 lists **`draft` | `active` | `suspended` | `cancelled` | `archived`** only; this PRD MUST NOT add a **`trial`** state or edges such as **`trial` → `active`** / **`trial` → `cancelled`** unless the BSS manifest enum is extended first. Trial periods MUST be expressed with **attributes** and/or **effective-dated composition** (trial **plan/SKU** via `PlanLink`, contract or subscription flags, Catalog trial offers) while the subscription occupies a manifest status — commonly **`draft`** before first paid activation and/or **`active`** when service is delivered under trial commercial rules. Where a plan defines time-bounded phases, the trial is modeled as the leading **plan phase** (`trial` → intro/evergreen; §1.4 Plan phase); **phase structure is Subscriptions SoR**, and the Rating gear resolves the active phase at `t` for pricing. End-of-trial without conversion uses normal transitions and composition changes (**cancel**, **changePlan**, or attribute update) without a dedicated trial terminal **status**. + +**Rationale**: Keeping trials out of the status enum preserves manifest conformance and keeps the state machine closed. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating` + +#### Trial commercial pattern + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-commercial-pattern` + +**Catalog** is authoritative for the **trial sellable definition** (trial plan/SKU, promotional **PriceWindow**, or time-bounded **`PlanLink`** to a trial offer). **Contract** carries **legal and commercial trial clauses** (notice, conversion, caps) where required. **Subscription** persists **evaluated** trial state as **attributes** plus effective **`PlanLink`** / snapshot pointers so Rating/Billing stay deterministic. **Attribute-only** trials without a Catalog-managed offer are **permitted** only when **Contract** still records the trial commercial terms (minimum viable for audit). Trial **runtime, conversion, expiry, and extension** are normative in §6.10 (absorbed from the predecessor — §2.2); **Design** defines concrete fields and optional trial-specific events while preserving the status enum. + +**Rationale**: A Catalog-first trial definition keeps trial economics reproducible and auditable without a status-machine fork. + +**Actors**: `cpt-cf-bss-subscriptions-actor-pricing`, `cpt-cf-bss-subscriptions-actor-contracts` + +#### TransitionRequest + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-transition-request` + +All mutating operations SHOULD be modeled as **`TransitionRequest`** with `type ∈ { activate, suspend, resume, cancel, changePlan, addAddOn, removeAddOn, updateQuantity, convertTrial, transfer, renew, unschedule, pauseCollection, resumeCollection, confirmAcceptance, extendTrial }`, `idempotencyKey`, `status ∈ { pending, approved, applied, failed }`. The base list is manifest §4.3; **`updateQuantity`** (§6.3), **`convertTrial`** (§6.10), and the SUB-D-08 completion set — **`renew`** (manual renewal, §6.5), **`unschedule`** (voids a pending scheduled intent, §6.1/AC 22), **`pauseCollection`**/**`resumeCollection`** (the §6.4 posture window), **`confirmAcceptance`** (§6.1 activation instants), **`extendTrial`** (§6.10, approval-gated) — are this-PRD extensions pending manifest alignment (§15). Without them, mutations the FRs already mandate would bypass the single commit path. High-risk types (e.g. **transfer**, **extendTrial**) require **Approval** records (manifest §4.3, §4.11). Duplicate `(subscriptionId, idempotencyKey)` MUST result in exactly **one** durable effect. + +**Rationale**: A uniform request envelope gives idempotency, approval hooks, and audit one shape. + +**Actors**: `cpt-cf-bss-subscriptions-actor-partner-admin`, `cpt-cf-bss-subscriptions-actor-platform-operator` + +#### Scheduled lifecycle intents + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-scheduled-intents` + +`cancel` MUST accept the same effective-timing vocabulary plan changes already use (§6.3): **`cancelMode ∈ { immediate, end_of_term, at(date) }`**; `suspend` MAY carry a **`resumeAt`** instant (scheduled resume). A non-immediate intent is stored as a **pending intent on the aggregate**: visible to the renewal job — a pending **end-of-term cancel MUST suppress renewal attempts and next-term recurring emission** (§6.5) — auditable, and **un-schedulable** (cancellable) until it takes effect; scheduling and un-scheduling both emit events. At the effective instant the normal transition executes with its full guard set (§6.1). (Actions the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-1; decision SUB-D-01.) + +**Rationale**: "Cancel at period end" is the most common self-service intent; unless it lives on the aggregate, the renewal job, Billing, and audit cannot see it. + +**Actors**: `cpt-cf-bss-subscriptions-actor-customer`, `cpt-cf-bss-subscriptions-actor-partner-admin` + +#### Commercial activation instants (booking / service / acceptance) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-activation-instants` + +The aggregate MUST record three commercial instants as attributes/evaluated fields — **`contractEffectiveAt`** (booking; referenced from the Contract), **`serviceActivatedAt`** (stamped at the `activate` commit), and **`customerAcceptedAt`** (stamped by an optional **acceptance confirmation** operation where the Contract carries acceptance clauses; absent clauses ⇒ it equals service activation per the Contract default). **No new statuses**: pending-activation / pending-acceptance interim states are rejected — the manifest enum stays closed, `draft` covers the pre-activation window. All three instants MUST ride the lifecycle events and the ASC input hooks (§5.1); recognition semantics stay Finance/Billing. (Actions the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-6; decision SUB-D-05.) + +**Rationale**: Enterprise/channel deals with acceptance clauses need booking, service, and acceptance instants for correct revenue timing — a single `activatedAt` collapses them. + +**Actors**: `cpt-cf-bss-subscriptions-actor-contracts`, `cpt-cf-bss-subscriptions-actor-finance-analyst` + +### 6.2 Versioning and Effective-Dated Composition + +#### Monotonic revision + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-monotonic-version` + +Each committed transition that changes commercial meaning MUST increment `version` (or append an immutable revision row) per manifest `Subscription.version`. + +**Rationale**: Optimistic concurrency and audit lineage hang off a monotonic revision. + +**Actors**: `cpt-cf-bss-subscriptions-actor-platform-operator` + +#### Effective-dated composition + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-effective-dated-composition` + +**`PlanLink`** `(subscriptionId, planId, effectiveFrom, effectiveTo)` governs which **plan** applies for rating/billing for interval intersection. **`AddOn`**: optional add-ons with `startDate`/`endDate`. Composition changes are effective-dated, never destructive edits of history. + +**Rationale**: Interval-based composition is what lets Rating and Billing agree on "which offer applied when". + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating`, `cpt-cf-bss-subscriptions-actor-billing` + +#### Snapshot discipline + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-snapshot-discipline` + +Subscription fee artifacts MUST carry **`pricingSnapshotRef`** / catalog identifiers consistent with the manifest §4.1 contract so Billing does not re-resolve mutable catalog state for **posted** periods. (The Subscriptions-written segment of the composed ref is the `(currency, region)` binding frozen at activation — see [rating PRD](../../rating/docs/PRD.md) §1.4 `pricingSnapshotRef`.) + +**Rationale**: Bill runs must reproduce from frozen inputs (AC 9), not from whatever the catalog says today. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing` + +#### PlanTier derivability + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-plantier-derivability` + +Effective **`PlanTier`** MUST be derivable from SKU/Plan at event time; changes MUST be **effective-dated** and **Policy-gated** (manifest §4.3 invariants). + +**Rationale**: Rating resolves tier-dependent pricing at `t`; a non-derivable tier breaks evaluation. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating` + +#### Per-sale brand attribution + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-sale-brand-attribution` + +A subscription created under a storefront brand MUST record the per-sale **`brandId`** and publish it in the pricing evaluation context, so the rating gear can match **brand-scoped** overlays ([rating PRD](../../rating/docs/PRD.md) §17.1 step 4 scope mapping). The registry `Product` declares brand *membership*; the **per-sale** `brandId` is a Subscriptions attribute. + +**Rationale**: Brand-scoped commercial terms are per-sale context only this gear can supply. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating` + +### 6.3 Plan Changes (Upgrade / Downgrade) + +#### Change boundary and mode + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-plan-change-boundary` + +`changePlan` (and add/remove add-on) updates **future** `PlanLink` rows and/or **schedules** a boundary at `effectiveFrom` (UTC). Subscriptions owns the **WHEN**: it sets `changeEffectiveAt` and `changeMode` (e.g. **immediate**, **next-cycle**, **end-of-term**) plus the up/down asymmetry policy, then emits **`SubscriptionPlanChanged`** carrying those inputs. + +**Rationale**: One owner for the change boundary keeps Rating slicing and Billing artifacts on the same instant. + +**Actors**: `cpt-cf-bss-subscriptions-actor-customer`, `cpt-cf-bss-subscriptions-actor-rating` + +#### Proration triggers + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-proration-triggers` + +| **Scenario** | **Subscription behavior** | **Billing alignment** | +|--------------|---------------------------|------------------------| +| Immediate change | Close prior plan link interval; open new `PlanLink` at `now` | Emit **delta recurring** or **one-time true-up** `BillableItem` per product policy; **never** edit posted invoice lines | +| Next cycle change | Schedule `effectiveFrom` at next anchor | First recurring charge under the new plan at the new period | +| Mid-cycle upgrade | Typically immediate; may generate **proration charge** or **credit** via Billing artifacts | Uses **credit/debit notes** if an invoice is already posted for the partial period | + +Trigger summary: `changePlan` with `effectiveFrom < nextAnchor` → proration evaluation for the recurring portion; a mid-cycle **PriceWindow** (Catalog) affecting the subscribed plan → the rating layer may change the **unit rate**, subscription composition unchanged; **suspend** mid-period → credit or pause per policy; **cancel** mid-period → early-termination fee or refund per **contract**, materialized as Billing artifacts. Manifest alignment: §4.3 lists **upgrades/downgrades with proration**; §4.4 forbids mutating posted invoices — proration appears as **new billable artifacts** or **adjusting documents**. + +**Rationale**: Proration is a coordination trigger here, never invoice mutation. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing` + +#### Proration ownership split + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-proration-ownership` + +Subscriptions owns the **change boundary and mode** (`changeEffectiveAt`, `changeMode`) and emits them on `SubscriptionPlanChanged`. The **Rating gear** owns the **proration math**: it rates `planA` over `[periodStart, changeEffectiveAt)` and `planB` over `[changeEffectiveAt, periodEnd)` (half-open, UTC), prorates the recurring component on the configured **`prorationBasis`** frozen in `pricingSnapshotRef`, and applies tier-`Q` / commitment carry-vs-reset per snapshot ([rating PRD](../../rating/docs/PRD.md) §6.11); the rating pipeline slices usage at the same boundary. This PRD MUST NOT specify proration day-count math or override resolution — it only fixes the trigger, the `effectiveFrom` semantics, and the "no posted-invoice mutation" invariant. + +**Rationale**: One math owner (Rating) + one boundary owner (Subscriptions) is the only split that keeps replay deterministic. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating` + +#### Backdated changes vs posted invoices + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-backdated-changes` + +A **commercial effective date** in the past for `PlanLink` MAY be allowed only when **contract/catalog rules** allow and **no posted invoice** would be contradicted; otherwise **split** at invoice cutoffs or issue **adjustments** (manifest §4.4). **Operational backdating** (e.g., an entitlement start in the past) MUST emit an **explicit** audit reason and may require **Policy** re-evaluation. A backdated `effectiveFrom` falling inside an already-posted invoice period MUST be rejected with a clear reason, directing the operator to the **adjustment** path (AC 6). + +**Rationale**: Backdating must never contradict posted financial documents. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing`, `cpt-cf-bss-subscriptions-actor-platform-operator` + +#### Overlapping subscriptions + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-overlap-cardinality` + +The manifest does **not** define global cardinality. **Default (resolved):** at most **one** **`active`** subscription per **`overlapScopeKey`**, where **`overlapScopeKey`** defaults to **`(payerTenantId, catalogSubscriptionProductKey)`** — `catalogSubscriptionProductKey` is the stable Catalog key for the sellable subscription product or family, owned by the Catalog registry; **Design** binds the stored field to a published SKU/product key. **Multiple concurrent `active`** subscriptions are **allowed** when they differ on **`overlapScopeKey`** (e.g. different **`resourceTenantId`**, **region**, **environment**, or **billing sub-account** when those dimensions are part of the key) or when the **Catalog** product template or **Contract** explicitly sets **`maxConcurrentActive` > 1** (or **unlimited** for wholesale/marketplace templates). If neither Catalog nor Contract sets a limit, **`maxConcurrentActive` = 1** for the default key applies. **Detection**: on **every entry into `active`** (`activate` **and** `resume`) **and on every committed change that mutates the key** (`changePlan` altering `catalogSubscriptionProductKey`; ownership **transfer** altering `payerTenantId`, §6.6), evaluate **`overlapScopeKey`** + **`maxConcurrentActive`**; reject or **queue** resolution **fail-closed** when the rule would break idempotent billing. A **cancel+new replacement** (cross-currency/region/frequency, §6.3) MAY overlap at the handover boundary only via an explicit **`supersedesSubscriptionId`** linkage — the successor's activation is exempt from the rule against exactly the subscription it supersedes, and only until that one's scheduled end. + +**Rationale**: An explicit default cardinality with a Catalog-owned key prevents double-billing without blocking legitimate multi-instance sales. + +**Actors**: `cpt-cf-bss-subscriptions-actor-catalog-registry` + +#### Seat / quantity change (updateQuantity) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-update-quantity` + +**`updateQuantity`** MUST be a first-class transition carrying the §6.3 envelope (`changeEffectiveAt`, `changeMode`). The seat count the rating gear reads (pricing `quantitySource = subscription_seat_count`, D-18) MUST come only from **committed** quantity transitions — auditable provenance, never an untyped attribute edit. The committed quantity MUST be **effective-dated** (interval/revision history, same discipline as `PlanLink` §6.2) so the read model can resolve **quantity @ `t`** for any charge instant and replay — a single mutable current value cannot satisfy the rating replay contract (§9.2). Default up/down asymmetry (owned here, like plan changes): **increases MAY take effect immediately** (prorated by the rating gear at the boundary); **decreases default to `next-cycle`**, are rejected below the quantity already consumed by committed assignments (e.g. seats in use) unless policy explicitly forces revocation, and `quantity = 0` is not a quantity change — it is a `cancel` (use the cancel envelope). Quantity changes are Policy-gated where the quantity is quota-bearing, and emit **`SubscriptionQuantityChanged`** — a composition-changing event consumed like `SubscriptionPlanChanged` (AC 11 payload rules apply; naming closed by SUB-D-09 in Design slice 08). (Actions the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-3; decisions SUB-D-02, SUB-D-09.) + +**Rationale**: Mid-period seat growth is the most frequent commercial mutation on B2B subscriptions; without a transition it has no Policy gate, no proration boundary, and no provenance. + +**Actors**: `cpt-cf-bss-subscriptions-actor-customer`, `cpt-cf-bss-subscriptions-actor-rating` + +#### Committed multi-step schedules (ramps) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-ramp-execution` + +A committed multi-step growth plan (ramp: e.g. 100 → 200 → 300 seats over quarters, or planB → planC at dates) is a **Contract term**: Contracts authors and owns the committed schedule; it materializes on the subscription as a sequence of **scheduled change intents** (`changePlan` / `updateQuantity` with future `changeEffectiveAt`), each executed here with the normal envelope, guards, and idempotency. No native schedule aggregate at launch; atomic multi-action submission (Zuora-Orders-style) is a Contracts/Design follow-up (§15). (Decision SUB-D-04 on the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-5.) + +**Rationale**: Negotiated ramps belong with the other negotiated commitments (Contracts SoR, cf. rating T-D-14); this gear stays the executor of well-formed intents. + +**Actors**: `cpt-cf-bss-subscriptions-actor-contracts` + +### 6.4 Suspension and Reactivation + +#### Suspend / resume semantics + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-suspend-resume` + +**Suspend**: transition to `suspended`; **revoke** or **freeze** entitlements per product policy; OSS deprovision/pause as confirmed by events. **Resume**: transition to `active`; **Policy allow** mandatory; re-issue entitlements; OSS reprovision. + +**Rationale**: Suspension is a governed, reversible posture change — not a soft delete. + +**Actors**: `cpt-cf-bss-subscriptions-actor-policy-engine`, `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +#### Suspension billing posture + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-suspension-billing-posture` + +Per the manifest risk note, **suspension vs billing alignment** MUST be explicit (e.g., **pause recurring** vs **continue to charge** for reserved capacity); the choice is **product policy** captured in subscription **attributes** and **contract** clauses. + +**Rationale**: Silent assumptions about billing-during-suspension are a revenue-leak and dispute source. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing`, `cpt-cf-bss-subscriptions-actor-contracts` + +#### Billing-only pause (collection paused, service running) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-collection-pause` + +The inverse of suspension MUST also be representable: a **`collectionPaused`** posture on an **`active`** subscription — service and entitlements untouched, recurring `BillableItem` emission for the paused window suppressed or deferred **per policy** (the same pause mechanism §6.5 already applies to blocked next-term recurring; Billing chooses the artifact treatment). The posture is an **auditable window** (start, end/limit, reason) bounded by Contract/Policy; it affects **collection only** — renewal **evaluation and term extension continue** per Contract, but the renewal **payment pre-check, grace entry, and dunning handoff are suspended** for renewals whose collection falls inside the pause window and run when the window ends (deferred collection; SUB-D-12) — otherwise a hardship pause would dun and suspend the very customer it protects (AC 29). Billing-cycle mechanics (pause-day limits, resume proration) remain open (§15). (Decisions SUB-D-03 + SUB-D-12 on the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-4; the predecessor's "Subscription Pause" scope row is honored by this shape.) + +**Rationale**: Hardship/dispute pauses must not revoke service — forcing `suspended` for them does the opposite of the intent. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing`, `cpt-cf-bss-subscriptions-actor-partner-admin` + +### 6.5 Renewal and Grace + +#### Renewal evaluation + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-renewal-evaluation` + +Contract §4.6 is the source: the `Renewal` entity includes `autoRenew(bool)` and term `(start,end)`; **evergreen** and **notice periods** are called out as manifest risks — product MUST define notice/opt-out behavior in contract templates. The Subscriptions **renewal job** evaluates `endDate` / term, emits the **renewal attempted** outcome; on success it extends the term; on failure it triggers the **failed renewal** path. **Manual renewal** (operator/customer-initiated **renew**) creates a new term window with fresh snapshot refs. + +**Rationale**: Renewal is Contract-driven; Subscriptions executes and audits it. + +**Actors**: `cpt-cf-bss-subscriptions-actor-contracts`, `cpt-cf-bss-subscriptions-actor-finance-analyst` + +#### Auto vs manual renewal + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-renewal-auto-manual` + +**Auto**: a scheduled transition extends `PlanLink`/`endDate` when the **payment method is valid** and the **contract allows** (Design: payment method checks). **Manual**: requires an explicit **TransitionRequest**; the same idempotency rules apply. Renewal attempts MUST be keyed to prevent **double term extension**. + +**Rationale**: Idempotent renewal attempts are what make retry-driven renewal jobs safe. + +**Actors**: `cpt-cf-bss-subscriptions-actor-payments` + +#### Renewal notices and opt-out + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-renewal-notices` + +Before an auto-renewal the system MUST emit **renewal notices** at configurable intervals — platform default **30/14/7/1 days** before term end; Contract / regional template MAY override within published bounds (same override pattern as the §6.5 grace policy). Notice **triggers and intervals** are owned here; **delivery channels** are Notifications/Comms. A **renewal opt-out** MUST be processed as a scheduled non-renewal at end of term (cancel at term boundary; no further renewal attempts), idempotently. + +**Rationale**: Evergreen/notice-period compliance is a manifest risk; auditable notices with an opt-out path are the mitigation. + +**Actors**: `cpt-cf-bss-subscriptions-actor-contracts`, `cpt-cf-bss-subscriptions-actor-customer` + +#### Failed renewal ladder + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-failed-renewal-ladder` + +| **Stage** | **Behavior** | +|-----------|--------------| +| Payment pre-check fails | Subscription remains **`active`** during **grace** (default **7 calendar days** unless **Contract** / regional template specifies another value within **Legal** bounds); then **`suspended`** or **`cancelled`** per the **grace policy** and contract ladder — Design encodes timers and Payments signals | +| Post-renewal billing failure | Hand off to **dunning** (Billing/Payments §4.4–4.5); the same **grace** rules and triggers apply | +| Idempotency | Renewal attempts keyed to prevent **double term extension** | + +**Grace (operational meaning).** Grace is the remediation window: the subscription **typically** stays **`active`** while retries/dunning run under the pause rules for blocked renewal recurring; exit is **time- or retry-driven** per the grace policy below, with **auditable** transitions. + +**Rationale**: A defined ladder gives partners time and signal before service degradation (AC 7). + +**Actors**: `cpt-cf-bss-subscriptions-actor-payments`, `cpt-cf-bss-subscriptions-actor-billing` + +#### Grace policy + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-grace-policy` + +These rules make **grace** operationally testable; **Contract** MAY override durations and ladder within **Legal** min/max where published. + +1. **Default duration before suspension:** **7 calendar days** from **grace start** (first auditable **failed renewal pre-check** or **aligned post-renewal billing failure** for the renewal attempt). **Jurisdiction:** where **Legal** requires different bounds, the effective duration MUST come from **Contract** (or the seller **regional template** referenced at sign); otherwise **7 days** is the platform default. +2. **Recurring during grace:** **`BillableItem(kind=recurring)` for the renewal term that is blocked by the failure MUST NOT be emitted** until renewal succeeds or grace resolves to failure (i.e. **paused** for that next-term recurring). **Usage-rated** charges **MAY continue** until **`suspended`** unless **Contract** or **Policy** explicitly freezes usage for the grace window. +3. **Per-contract configurability and SoR:** **configurable per contract** (or per contract template). **Authoritative** commercial terms (**grace length**, ladder, billing posture) live on **Contract** / **`Renewal`** and related clauses (§4.6). **Subscription** MUST store **evaluated fields** (§1.4) at renewal evaluation time for **audit**, **idempotent** renewal jobs, and replay. +4. **Grace → `suspended` (or `cancelled`) trigger:** **hybrid — whichever is first:** **(i)** the current grace interval **elapses** without successful renewal, **or** **(ii)** **Payments** declares **no further automated retries** for the failure. Move to **`cancelled`** only per **contract-defined** steps after suspend or final dunning (not implied by (i) or (ii) alone unless **Contract** says so). +5. **Late success inside grace (term continuity):** when renewal succeeds during grace, the new term MUST start at the **old term end** (backdated — continuous coverage, no gap), and the previously blocked next-term recurring is emitted with its **original** `(subscriptionId, billing period)` key. **Resume after a grace-driven suspension** requires the blocking payment failure to be **resolved** (successful renewal/payment, or an audited operator override) — `resume` alone MUST NOT restore unpaid service (§6.1 guard table). + +**Rationale**: Grace defaults answered in the PRD (not left to Design) are what make the ladder product-testable. + +**Actors**: `cpt-cf-bss-subscriptions-actor-contracts`, `cpt-cf-bss-subscriptions-actor-payments` + +### 6.6 Multi-Tenant Ownership + +#### Tenant axes + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-tenant-axes` + +| **Axis** | **Use** | +|----------|---------| +| `resourceTenantId` | Operational owner of resources tied to the subscription | +| `payerTenantId` | Financial responsibility (consolidated billing) | +| `sellerTenantId` | Channel/marketplace seller when applicable | + +**Rationale**: The three axes are the multi-tenant backbone every downstream consumer keys on. + +**Actors**: `cpt-cf-bss-subscriptions-actor-ams` + +#### Delegation proofs + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-delegation-proofs` + +Cross-tenant admin actions MUST carry an **auditable delegation proof** (manifest §2.1.3); an action without a valid delegation MUST be rejected, and the audit record MUST include the explicit proof reference (AC 10). + +**Rationale**: Reseller hierarchies make undelegated cross-tenant mutation a critical-severity risk. + +**Actors**: `cpt-cf-bss-subscriptions-actor-partner-admin` + +#### Hierarchy by reference + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-hierarchy-reference` + +Commercial roll-ups follow **account** and **OrgTier** context from AMS; Subscriptions MUST NOT invent tenant topology — it **references** the AMS/BSS account binding only. + +**Rationale**: One identity SoR; BSS projections never fork it. + +**Actors**: `cpt-cf-bss-subscriptions-actor-ams` + +### 6.7 Event Model + +#### Producer inventory + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-event-producers` + +Subscriptions **MUST emit** (CloudEvents 1.0, tenant-scoped, minimal PII): `SubscriptionCreated`, `SubscriptionActivated`, `SubscriptionSuspended`, `SubscriptionResumed`, `SubscriptionCancelled`, **`SubscriptionPlanChanged`**, `BillableItemCreated(kind=recurring)`, `EntitlementIssued`, `EntitlementRevoked`, `OwnershipTransferRequested`, `OwnershipTransferApproved`, `OwnershipTransferCompleted` (transfer per manifest §4.11). This is the manifest **baseline**; the **secondary auditable events** this PRD mandates elsewhere — intent scheduling/un-scheduling (AC 22), renewal outcome and grace entry/exit (AC 7), notice triggers (AC 19), the collection-pause window (AC 24), the quantity composition event (AC 23), the trial conversion/extension events (AC 16–17), acceptance confirmation (AC 25), and quota warning/exhaustion/restore (AC 14) — extend this inventory with naming closed normatively in **Design slice 08** (SUB-D-09). + +**Rationale**: The manifest §4.3 producer inventory is the downstream integration surface. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing`, `cpt-cf-bss-subscriptions-actor-rating`, `cpt-cf-bss-subscriptions-actor-analytics` + +#### Payload completeness (business rules, not schema) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-event-payload-completeness` + +This PRD MUST NOT enumerate **event attribute/extension names** or wire formats; those belong in **Design** (`DESIGN-subscriptions-*`). Normative intent only: every **lifecycle** producer event MUST carry **enough identity, tenancy, correlation, and time** context that consumers can **route**, **deduplicate**, and **replay** in stream order **without** a mandatory undocumented side channel for those concerns. Events that **change commercial composition** (including plan changes) MUST carry **enough snapshot-oriented commercial context** that **Rating** and **Billing** can stay **aligned** on the same effective offer for the affected period and process commands **idempotently** (AC 11). + +**Rationale**: Sufficiency, not schema, is the PRD-level contract; Design owns the field matrix. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating`, `cpt-cf-bss-subscriptions-actor-billing` + +#### Consumers + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-event-consumers` + +**OSS Provisioning** acts on subscription/entitlement changes; **Policy Engine** receives post-change confirmations as required by integration Design; **Billing** ingests recurring items and aligns periods/proration; **Analytics/DWH** consumes facts. + +**Rationale**: The manifest §4.3 consumer list bounds who may depend on these streams. + +**Actors**: `cpt-cf-bss-subscriptions-actor-oss-provisioning`, `cpt-cf-bss-subscriptions-actor-analytics` + +#### Ordering + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-event-ordering` + +Order **MUST** be preserved **within `(tenantId, aggregateId)`** with **`aggregateId = subscriptionId`** (the manifest §4.2 note on rating aggregation applies analogously to subscription command handling). Per that note and the [rating PRD](../../rating/docs/PRD.md), `tenantId` in this ordering key denotes the **`resourceTenantId`** (operational owner) so that subscription command ordering and downstream Rating partition ordering share the same key. The ordering tenant is **pinned at creation** as an immutable **`orderingTenantId`** (= `resourceTenantId` at creation): an ownership **transfer** (§6.6) rebinds the commercial tenant axes but **never** the ordering/partition key — pre- and post-transfer events stay on one partition, and `OwnershipTransferCompleted` carries both old and new axes on that partition so consumers re-key their own projections (SUB-D-06, AC 26). + +**Rationale**: A shared ordering key is what lets Rating consume composition changes without reorder hazards (AC 3). + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating` + +### 6.8 Billing Alignment + +#### Recurring idempotency + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-recurring-idempotency` + +**`BillableItem(kind=recurring)`** MUST be idempotent on **`(subscriptionId, billing period)`** (manifest §4.3): at most one recurring item per key even under bill-run retries (AC 5). **Ownership split (SUB-D-07):** what this gear emits is the **money-free recurring period fact** — period identity from the billing anchor, the §6.8 traceability tuple, `pricingSnapshotRef`, and the pause/intent posture; the **rating gear prices** the recurring component from the frozen snapshot (flat / per-unit × committed quantity / the hybrid recurring line — rating PRD §6) and the **priced line inherits the fact's idempotency key**; Billing posts. This gear owns the period **cut** (anchor, pauses, pending intents, the key) and never the amount — the same WHEN/MATH split as plan changes (AC 27). + +**Rationale**: Recurring double-charges are the classic bill-run failure; the key kills the class. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing` + +#### No retro-edit + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-no-retro-edit` + +Posted invoice lines MUST NOT be rewritten; subscription corrections emit **new** billable or **adjustment** paths (manifest §4.4). + +**Rationale**: Posted-document immutability is the financial-audit bedrock shared with Billing and Rating. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing` + +#### Traceability + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-billing-traceability` + +Items MUST trace to **`subscriptionId`**, **`skuId`/`planId`/`priceId`**, **`pricingSnapshotRef`** (manifest §4.4 itemization). + +**Rationale**: Charge-to-catalog lineage is what partners and auditors reconcile against. + +**Actors**: `cpt-cf-bss-subscriptions-actor-finance-analyst` + +#### Dataset separation + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-dataset-separation` + +Subscription state ≠ invoice posted state; late usage adjustments remain a Rating→Billing concern (manifest §4.2). This section states **Billing invariants** for artifacts Subscriptions coordinates with Billing; it does **not** describe client control-plane operations (§9.1) — REST paths, HTTP methods, error codes, and header field names belong in **Design**. + +**Rationale**: Keeping lifecycle state and posted financial state as separate datasets prevents accidental coupling. + +**Actors**: `cpt-cf-bss-subscriptions-actor-billing` + +### 6.9 Entitlement Lifecycle (Issue, Revoke, Point-of-Use) + +#### Issue/revoke from subscription transitions + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-entitlement-issue-revoke` + +On a successful **resource-affecting** transition whose outcome requires entitlement grants or withdrawals (activate, suspend, resume, cancel, Policy-gated plan/add-on changes), Subscriptions MUST **issue** or **revoke** entitlements to match the new posture and emit auditable producer events aligned to **`EntitlementIssued`** / **`EntitlementRevoked`** (AC 12). + +**Rationale**: Entitlement posture must be a deterministic function of committed subscription state. + +**Actors**: `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +#### Assignment from plan definitions + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-entitlement-assignment` + +On activation, phase boundary, and committed plan/add-on change, entitlements (feature flags, usage quotas, resource quotas) MUST be assigned from the **plan's published grant set** — including the per-phase map where the plan is phased (pricing gear, `phase→grant-set` — D-41) — with immediate or end-of-cycle effective dates aligned to the transition's `changeMode`. The catalog authors the templates; this gear resolves and materializes the assignment per subscription. + +**Rationale**: One authoring home (catalog grant sets) + one assignment home (here) keeps entitlements reproducible per revision. + +**Actors**: `cpt-cf-bss-subscriptions-actor-pricing` + +#### Point-of-use check contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-entitlement-check-contract` + +This gear MUST expose a real-time **entitlement check** read contract (feature-flag decision, quota remaining, limit state) for OSS enforcement at **p95 < 100ms** (§7.1), tenant-isolated and cache-friendly. OSS enforces (allow/block/degrade); this gear never executes enforcement, it serves the **decision state**. Entitlement updates MUST propagate to the check surface within the §7.1 propagation baseline. + +**Rationale**: Real-time access control is the predecessor's core promise; the contract boundary keeps SoR here and enforcement in OSS. + +**Actors**: `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +#### Quota tracking, soft/hard limits + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-entitlement-quota-limits` + +Usage MUST be tracked against entitlement quotas (usage aggregates fed by the rating pipeline). Crossing a **soft limit** MUST emit an auditable warning event and MAY route overage per the plan's policy; reaching a **hard limit** MUST flip the check state to blocking — never a silent overrun. Exhaustion and restore (new cycle, quota increase, plan change) MUST emit auditable events. Mid-request behavior at the exhaustion instant (graceful degradation vs hard block) is an OSS/Design decision (§15). + +**Rationale**: Quota state is commercial state — it must be auditable and deterministic, not an OSS-side side effect. + +**Actors**: `cpt-cf-bss-subscriptions-actor-rating`, `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +### 6.10 Trial Runtime and Conversion + +#### Trial provisioning + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-provisioning` + +A trial subscription MUST be created from a **Catalog-defined trial offer** (trial plan/SKU, promotional PriceWindow, or leading trial **phase**) with configurable duration, per §6.1 trial rules (no `trial` status; evaluated attributes + `PlanLink`/snapshot pointers persisted). Feature access during trial follows the **trial-phase grant set** (§6.9 assignment). + +**Rationale**: Catalog-first trials keep trial economics reproducible; the phase machinery already carries duration and grants. + +**Actors**: `cpt-cf-bss-subscriptions-actor-pricing` + +#### End-of-trial conversion + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-conversion` + +At trial end the system MUST convert per the plan's phase schedule (`convertsToPhaseId`): advance the phase boundary, authorize payment where required (Payments, per Design — without re-entering payment details where a method is on file), re-issue entitlements per the target phase's grant set with **continuity** (no access gap), and emit the composition-changing event. A payment failure at conversion follows the §6.5 grace ladder. Conversion processing MUST be idempotent (**zero missed / zero double conversions**). + +**Rationale**: Conversion is the revenue moment of a trial; it must be deterministic, continuous, and ladder-protected. + +**Actors**: `cpt-cf-bss-subscriptions-actor-payments`, `cpt-cf-bss-subscriptions-actor-rating` + +#### Early conversion (convertTrial) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-early-conversion` + +**`convertTrial`** MUST be a first-class operation ("skip the trial, start paying now"): an explicit `TransitionRequest` that advances the phase boundary to `now` (the phase-axis twin of `changePlan` — the boundary instant is consumed by the rating gear like any `changeEffectiveAt`), re-issues entitlements per the target phase, and emits a first-class conversion event — Policy-gated where resource-affecting, idempotent on `(subscriptionId, idempotencyKey)`. This extends the manifest §4.3 `TransitionRequest.type` list — manifest alignment tracked in §15. (Actions the [gap analysis](./STRIPE-ZUORA-GAP-ANALYSIS.md) G-2.) + +**Rationale**: Modeling early conversion as an untyped attribute edit loses the Policy gate, entitlement re-issue, eventing, and idempotency. + +**Actors**: `cpt-cf-bss-subscriptions-actor-customer`, `cpt-cf-bss-subscriptions-actor-partner-admin` + +#### Trial expiry without conversion + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-expiry` + +An unconverted trial at expiry MUST follow the configured end action using normal transitions (typically **cancel**; never a bespoke terminal status — §6.1): entitlements removed, auditable events emitted, and an optional **win-back hook** event published (campaign content and delivery = Notifications/Comms, out of scope §5.2). + +**Rationale**: Expiry must be as governed and auditable as any other lifecycle exit. + +**Actors**: `cpt-cf-bss-subscriptions-actor-oss-provisioning` + +#### Trial extension + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-fr-trial-extension` + +A trial extension MUST be an **approval-gated** operation (Approval record per §6.1 high-risk pattern) that moves the conversion date and the trial-phase end consistently, with audit; the approval policy (automatic / manual / threshold-based) is a Product decision (§15). + +**Rationale**: Extensions change revenue timing; they need the same governance as other high-risk transitions. + +**Actors**: `cpt-cf-bss-subscriptions-actor-partner-admin` + +## 7. Non-Functional Requirements + +### 7.1 NFR Inclusions + +> Baselines carried forward from `PRD-subscriptions-entitlements-202601120119` (PRD-0001 SLAs); they MUST be reconciled with the program NFR workshop — the workshop overrides on conflict (§15). + +#### Lifecycle control-plane latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-nfr-lifecycle-latency` + +Synchronous commit class for `activate`, `suspend`, `resume`, `changePlan`, `cancel`: **p95 < 1s**. + +#### Entitlement check latency + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-nfr-entitlement-check-latency` + +Subscription-backed entitlement check: **p95 < 100ms**. + +#### Recurring generation cut + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-nfr-recurring-cut` + +Recurring `BillableItem` generation (schedule/cut): **daily by 00:00** per the productized window in Design. + +#### Proration monetary accuracy + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-nfr-proration-accuracy` + +Proration / plan-change monetary alignment: **100% accuracy** vs policy (the math itself is the Rating gear's; this NFR binds the end-to-end alignment). + +#### Horizontal partitioning and scale + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-nfr-horizontal-partitioning` + +Horizontal partitioning by tenant for subscription reads/writes; support **100K+ active subscriptions per tenant**; bulk read models for account roll-ups; avoid N+1 Policy calls via batch where contractually safe. + +#### Operational latency baselines + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-nfr-operational-baselines` + +Carried from the predecessor's module specifications, pending the same NFR-workshop reconciliation: state transition **p95 < 500ms**; subscription query **p95 < 200ms**; entitlement update propagation to the check surface **< 5s**; event delivery to consumers **p95 < 30s**; recurring charge accuracy **100%** with **zero duplicates** (§6.8). + +### 7.2 NFR Exclusions + +- **PSP/dunning retry timing internals** — Payments/Billing (§4.5); this PRD consumes signals only. +- **Tax computation and statutory invoicing performance** — Billing/Tax. +- **Revenue recognition throughput** — Finance/Billing. + +## 8. Five Quality Vectors Analysis + +| **Quality Vector** | **Show-Stopper Requirements** | **Rationale** | +|--------------------|-------------------------------|---------------| +| **🚀 Efficiency** | Bulk read models for account rollups; avoid N+1 Policy calls via batch where contractually safe; **95% reduction in manual subscription operations** — zero manual intervention for standard lifecycle transitions. | Subscription lists power portals and support at scale; manual ops do not scale past ~1000 customers. | +| **🔒 Reliability** | State machine + idempotency + ordering invariants; **zero missed recurring charges**; DLQ/replay for failed transitions; daily reconciliation checks (§17.1). | Revenue and entitlement mistakes are existential risk; missed charges are silent leakage. | +| **⚡ Performance** | The §7.1 baselines: lifecycle control-plane **p95 < 1s**; entitlement check **p95 < 100ms**; recurring generation **daily by 00:00**; proration alignment **100% accuracy**; horizontal partitioning by tenant. Baseline from the predecessor PRD (PRD-0001 SLAs) — the program NFR workshop overrides if in conflict. | The same SLAs block onboarding and billing accuracy at scale; explicit targets make the vector testable. | +| **🛡️ Security** | Strict tenant isolation; delegation proofs for cross-tenant ops; audit on every transition with SOX-grade correlation IDs; encryption at rest and in transit. | Commercial data is sensitive; cross-tenant leakage is critical severity. | +| **🔄 Versatility** | Support multiple commercial models (usage, recurring, hybrid, fixed-term, evergreen, prepaid) via `PlanLink`/add-ons and Contract terms without breaking aggregate ordering; extensible entitlement types (flags, quotas, limits). | Channel SKUs and enterprise deals vary widely; product evolution requires extensible entitlements. | + +## 9. Public Library Interfaces + +### 9.1 Public API Surface + +#### Control-plane operations contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-interface-control-plane` + +**Shapes in Design**; here: **business operations** (what clients must be able to do), not REST mapping. How each operation is exposed (paths, methods, idempotency/concurrency header bindings, OpenAPI) is specified in **Design** alongside error contracts. Resources (names per manifest): `Subscription`, `Entitlement`, `AddOn`, `PlanLink`, `TransitionRequest`, `Approval`. + +| **Operation** | **Verb(s)** | **Idempotency / concurrency (requirements)** | +|---------------|-------------|-----------------------------------------------| +| create | `create` | **Idempotency key** on create | +| get / list | `get`, `list` | — | +| activate / suspend / resume / cancel | `activate`, `suspend`, `resume`, `cancel` | **Idempotency key** + **optimistic concurrency** on subscription **version** (Design maps to concrete headers); `cancel` carries `cancelMode`, `suspend` MAY carry `resumeAt` (§6.1) | +| plan change | `changePlan` | same | +| add-on change | `addAddOn`, `removeAddOn` | same | +| quantity change | `updateQuantity` | same; §6.3 envelope (SUB-D-02) | +| trial conversion / extension | `convertTrial`, `extendTrial` | same; `extendTrial` requires **Approval** (§6.10) | +| manual renewal | `renew` | same; keyed against double term extension (§6.5) | +| un-schedule pending intent | `unschedule` | same; references the pending intent it voids (§6.1, AC 22) | +| collection pause window | `pauseCollection`, `resumeCollection` | same; auditable window attributes (§6.4) | +| acceptance confirmation | `confirmAcceptance` | same; stamps `customerAcceptedAt` (§6.1) | +| ownership transfer | `transfer` | same; requires **Approval** + delegation proof (§6.6) | +| entitlement (internal / admin) | `issueEntitlement`, `revokeEntitlement` | **Audit** mandatory; mutating paths in Design | + +### 9.2 External Integration Contracts + +#### Billing handoff contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-contract-billing-handoff` + +**Protocol/Format**: `BillableItemCreated(kind=recurring)` — the **money-free recurring period fact**, idempotent per `(subscriptionId, billing period)`, carrying stable `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef` + the pause/intent posture, **no monetary column**; the rating gear prices the recurring component and the priced line inherits the fact's key before Billing posts (SUB-D-07, §6.8); proration materialized only as new billable or adjusting artifacts; posted invoices immutable (§6.8). + +#### Rating read-model contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-contract-rating-read-model` + +**Protocol/Format**: composition read models exposing effective `PlanLink`/`AddOn` intervals, `PlanTier` @ `t`, active **plan phase** at `t`, the plan-change `(changeEffectiveAt, changeMode)`, the **committed seat quantity @ `t`** (effective-dated, §6.3 — pricing `quantitySource = subscription_seat_count`), the **`priceEligibility` inputs** (`activatedAt`, the bound `cohort` via the pinned price id), and the per-sale **`brandId`** evaluation context (§6.2, AC 20); ordering shared on `(resourceTenantId, subscriptionId)` with the **pinned `orderingTenantId`** stable across transfers (§6.7, SUB-D-06). ([Rating PRD](../../rating/docs/PRD.md) §9.2 "Subscriptions input contract" is the counterpart — its field list names the seat count and `priceEligibility` inputs explicitly; the two lists MUST stay mirror-aligned.) + +#### Contracts input contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-contract-contracts-input` + +**Protocol/Format**: signed terms, `Renewal` (`autoRenew`, term windows), grace ladder / regional-template values, `PriceOverride` windows — consumed via events (`ContractSigned`, `ContractRenewed`, …) + read models; Subscriptions stores **evaluated fields** at renewal evaluation time (§6.5). + +#### Policy Engine gate contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-contract-policy-gate` + +**Protocol/Format**: pre-commit allow/deny + `reasonCodes` for every resource-affecting transition; fail-closed on deny or unavailability; post-change confirmations per integration Design (manifest §6). + +#### OSS provisioning contract + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-contract-oss-provisioning` + +**Protocol/Format**: provision/deprovision/pause work orders confirmed by events; entitlement issue/revoke aligned to committed transitions; BSS never mutates OSS topology directly. + +#### Payments failure-signal contract + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-contract-payments-signals` + +**Protocol/Format**: payment pre-check outcomes and retry-exhaustion declarations consumed by the renewal/grace ladder (§6.5); PSP webhooks and dunning handoff payloads are Design scope. + +## 10. Use Cases + +#### Subscription administration (suspend / resume / cancel) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-usecase-admin-console` + +**Actor**: `cpt-cf-bss-subscriptions-actor-partner-admin` + +**Preconditions**: +- An existing subscription within the admin's (delegated) scope. + +**Main Flow**: +1. Open the subscription. +2. Request suspend / resume / cancel. +3. Track the `TransitionRequest` outcome (pending → approved → applied). +4. Perform bulk operations (batch create/update/cancel) — async with status tracking. +5. Reconcile recurring charges against Billing postings (§17.1). + +**Postconditions**: +- Committed transition with Policy pre-check, OSS confirmation where required, entitlements aligned, events emitted. + +**Alternative Flows**: +- **Policy deny**: state unchanged; deny + `reasonCodes` surfaced. +- **Missing delegation proof** (cross-tenant): rejected; audit records the attempt. + +#### Plan change with proration preview + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-usecase-plan-change` + +**Actor**: `cpt-cf-bss-subscriptions-actor-customer` + +**Preconditions**: +- An `active` subscription; a published target plan. + +**Main Flow**: +1. Select the target plan. +2. Preview the proration charge (calculation authority: the Rating gear's evaluation; surfaced via the preview owner defined in Design). +3. Confirm effective timing (`immediate` / `next-cycle` / `end-of-term`). + +**Postconditions**: +- Scheduled or immediate `PlanLink` boundary; `SubscriptionPlanChanged` emitted with `(changeEffectiveAt, changeMode)`. + +**Alternative Flows**: +- **Backdated `effectiveFrom` contradicting a posted invoice**: rejected with a clear reason → adjustment path (AC 6). + +#### Renewal monitoring and dunning export + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-usecase-renewal-monitoring` + +**Actor**: `cpt-cf-bss-subscriptions-actor-finance-analyst` + +**Preconditions**: +- Renewal jobs running; some subscriptions in grace. + +**Main Flow**: +1. Filter failing renewals (grace state, `graceEndsAt`, ladder variant). +2. Export the audit trail for dunning/compliance. + +**Postconditions**: +- Auditable dunning workflow input; no state mutation. + +#### Entitlement configuration and monitoring + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-usecase-entitlement-dashboard` + +**Actor**: `cpt-cf-bss-subscriptions-actor-product-manager` + +**Preconditions**: +- Published plans with grant sets; active subscriptions. + +**Main Flow**: +1. Configure entitlement templates for plans (authored as catalog grant sets; per-phase where phased). +2. Monitor quota usage and limit state across subscriptions (soft/hard, §6.9). +3. Review compliance/violations; export entitlement reports. + +**Postconditions**: +- Template changes flow through catalog publish; monitoring here is read-only. + +## 11. User Interaction and Design + +| **Interface Name** | **Role** | **Steps** | **Mockup Screen** | +|--------------------|----------|-----------|-------------------| +| Subscription admin console | As a Partner Admin, I want to suspend, resume, or cancel a customer's subscription and track `TransitionRequest` outcomes so that I can respond to operational and payment issues without requiring engineering involvement | 1. Open subscription
2. Request suspend/resume/cancel
3. Track TransitionRequest | — | +| Plan change wizard | As a Customer or Partner Admin, I want to preview the proration charge before confirming a plan change so that I can avoid unexpected charges | 1. Select target plan
2. Preview proration (charge-preview owner per Design; calculation authority = Rating gear)
3. Confirm effective timing | — | +| Renewal status | As a Finance analyst, I want to filter failing renewals and export the audit trail so that I can manage dunning workflows and compliance reporting | 1. Filter failing renewals
2. Export audit trail | — | +| Customer self-service portal | As an End Customer, I want to view my subscription status, monitor usage against my quotas, and request changes or cancellation so that I control my service access independently | 1. View status & details
2. Monitor usage vs quotas
3. Request upgrade/downgrade or cancel
4. View history & billing info | — | +| Entitlement dashboard | As a Product Manager or Entitlement Admin, I want to configure entitlement templates and monitor quota usage so that I can manage product access controls effectively | 1. Configure templates (catalog grant sets)
2. Monitor quotas & limits
3. Review compliance
4. Export reports | — | + +## 12. Acceptance Criteria + +> **As a** commercial platform **I want** governed subscription lifecycles **so that** revenue, entitlements, and invoices stay consistent across tenants and channels. + +### Lifecycle and guards + +**1. Policy gate on resource-affecting transitions** +- **Given** a transition that changes entitlements or provisioned resources +- **When** Subscriptions processes the request +- **Then** a Policy Engine **pre-check** MUST complete before commit +- **And** on deny the subscription state MUST NOT change + +**2. Idempotent transitions** +- **Given** two identical requests with the same `(subscriptionId, idempotencyKey)` +- **When** both are processed +- **Then** exactly one durable effect MUST result + +**3. Ordering** +- **Given** multiple events for the same subscription +- **When** processed by consumers that require ordering +- **Then** ordering within `(tenantId, subscriptionId)` MUST be preserved + +### Composition and billing + +**4. Plan change coherence** +- **Given** a plan change with `effectiveFrom` +- **When** Rating and Billing evaluate charges for period `P` +- **Then** both MUST use the same **effective PlanLink set** for `P` (via read models + snapshots) + +**5. Recurring idempotency** +- **Given** a closed billing period for `subscriptionId` +- **When** the bill run retries +- **Then** at most one recurring **`BillableItem`** per `(subscriptionId, period)` MUST be posted + +**6. Backdated plan link rejected when invoices would be contradicted** +- **Given** a backdated **`effectiveFrom`** for a plan change that falls within an **already-posted invoice** period +- **When** the change is submitted +- **Then** the system MUST reject it with a clear reason +- **And** the operator MUST be directed to use the **adjustment** path instead + +### Renewal and activation eligibility + +**7. Failed renewal** +- **Given** a renewal attempt with failed payment per policy +- **When** the renewal outcome is applied +- **Then** the subscription MUST remain **`active`** during the grace window or transition to **`suspended`** / **`cancelled`** per the published Contract and §6.5 grace policy (grace is a remediation window, **not** a status) +- **And** MUST emit auditable events for downstream dunning + +**8. Overlap conflict** +- **Given** a product rule forbidding duplicate active subscriptions for a scope +- **When** activation would violate the rule +- **Then** activation MUST be rejected with a clear, actionable reason + +### Versioning, multi-tenant, and events + +**9. Snapshot discipline for bill run reproducibility** +- **Given** a billing period `P` for which recurring charges were generated using **PlanLink** set **`L`** +- **When** the bill run is replayed after a plan change that took effect **after** `P` +- **Then** the replay MUST produce the **same** charges using **PlanLink** set **`L`** (via snapshot refs) without re-consulting mutable catalog state + +**10. Delegation proof for cross-tenant operation** +- **Given** an admin action on a subscription owned by Tenant **A**, performed by an actor in Tenant **B**'s organizational scope +- **When** the action is processed +- **Then** the audit record MUST include an explicit **delegation proof** reference +- **And** the action MUST be rejected if no valid delegation exists + +**11. Event payload completeness** +- **Given** any subscription lifecycle event from the §6.7 producer list that **Rating** or **Billing** consumes +- **When** the event is published +- **Then** the payload MUST be sufficient for **idempotent** consumer handling of the declared commercial effect **without** relying on undocumented side channels +- **And** for **composition-changing** events (including plan changes), the payload MUST be sufficient for **Rating** and **Billing** to apply the **same** effective commercial snapshot intent for the affected period +- **And** concrete **CloudEvents** attributes, extensions, and required-field matrices MUST appear only in **`DESIGN-subscriptions-*`** + +**12. Entitlement issue and revoke from subscription transitions** +- **Given** a successful **resource-affecting** subscription transition whose outcome requires entitlement grants or withdrawals +- **When** the transition is committed +- **Then** Subscriptions MUST **issue** or **revoke** entitlements to match the new posture and emit auditable producer events aligned to **`EntitlementIssued`** / **`EntitlementRevoked`** in the §6.7 inventory + +### Entitlements and trials + +**13. Entitlement check latency** +- **Given** an entitlement check request from OSS for a subscription-backed grant +- **When** the check is served +- **Then** the response MUST meet **p95 < 100ms** with tenant isolation +- **And** the decision state MUST reflect committed subscription posture within the §7.1 propagation baseline + +**14. Soft and hard limit behavior** +- **Given** usage tracked against an entitlement quota +- **When** the soft limit is crossed +- **Then** an auditable warning event MUST be emitted and overage handled per the plan's policy +- **And** when the hard limit is reached the check state MUST turn blocking — never a silent overrun + +**15. Entitlement update on plan change** +- **Given** a committed upgrade/downgrade with a target grant set +- **When** the change takes effect (per `changeMode`) +- **Then** entitlements MUST be updated to the target set with immediate or end-of-cycle effective dates matching the change boundary + +**16. Trial auto-conversion continuity** +- **Given** a trial subscription with a payment method on file reaching trial end +- **When** conversion runs +- **Then** the phase MUST advance per `convertsToPhaseId` with entitlement continuity (no access gap) and exactly one conversion (idempotent) +- **And** a payment failure at conversion MUST enter the §6.5 grace ladder + +**17. Early conversion (`convertTrial`)** +- **Given** an active trial and an explicit `convertTrial` request +- **When** it is committed +- **Then** the phase boundary MUST move to `now`, the boundary instant MUST be consumed by the rating gear like a plan-change boundary, entitlements MUST be re-issued per the target phase, and a first-class conversion event MUST be emitted + +**18. Trial expiry without conversion** +- **Given** a trial reaching expiry without conversion +- **When** the end action runs +- **Then** the subscription MUST follow the configured normal transition (no bespoke terminal status), entitlements MUST be removed +- **And** the win-back hook event MAY be emitted (delivery via Notifications) + +**19. Renewal notices** +- **Given** an auto-renewing subscription approaching term end +- **When** the configured notice intervals (default 30/14/7/1 days) are reached +- **Then** notice trigger events MUST be emitted for delivery via Notifications +- **And** an opt-out MUST result in scheduled non-renewal at term end, idempotently + +**20. Per-sale brand attribution** +- **Given** a subscription created under a storefront brand +- **When** the pricing evaluation context is published +- **Then** the per-sale `brandId` MUST be present so brand-scoped overlays can match (rating §17.1 step 4) + +### Scheduled intents, quantity, pause, activation instants + +**21. Scheduled end-of-term cancel** +- **Given** a pending `cancelMode = end_of_term` intent on an auto-renewing subscription +- **When** the renewal window arrives +- **Then** no renewal attempt MUST run and no next-term recurring `BillableItem` MUST be emitted +- **And** at the term boundary the subscription MUST transition to `cancelled` with the normal guards and events + +**22. Un-scheduling a pending intent** +- **Given** a pending scheduled intent (cancel or resume) +- **When** it is un-scheduled before its effective instant +- **Then** no transition occurs +- **And** both the scheduling and the un-scheduling MUST be auditable events + +**23. Quantity change provenance and boundary** +- **Given** an `updateQuantity` request with a `changeMode` +- **When** it is committed +- **Then** the seat count consumed by rating MUST change exactly at the boundary (increases MAY be immediate and prorated; decreases default to next-cycle) +- **And** the composition-changing event MUST satisfy the AC 11 payload rules + +**24. Collection pause** +- **Given** an `active` subscription with a `collectionPaused` window +- **When** recurring generation runs for that window +- **Then** no recurring `BillableItem` MUST be posted (suppressed or deferred per policy) +- **And** service and entitlements MUST remain unaffected, with the pause window auditable + +**25. Activation instants** +- **Given** an activation under a Contract with acceptance clauses +- **When** lifecycle events are emitted +- **Then** `serviceActivatedAt` MUST be stamped at the activate commit, `contractEffectiveAt` referenced from the Contract, and `customerAcceptedAt` stamped by the acceptance confirmation +- **And** all three MUST ride the ASC input hooks + +### Ordering pin, recurring split, void, pause interplay + +**26. Ordering stability across ownership transfer** +- **Given** a subscription whose `resourceTenantId` is rebound by a completed ownership transfer +- **When** events before and after the transfer are consumed in stream order +- **Then** all of them MUST share the one partition keyed by the pinned `orderingTenantId` (stamped at creation, immutable) +- **And** `OwnershipTransferCompleted` MUST carry both the old and the new tenant axes on that partition + +**27. Recurring period fact priced exactly once** +- **Given** a recurring period cut for `(subscriptionId, billing period)` +- **When** the period fact is emitted and priced +- **Then** the fact MUST carry no monetary amount, the rating gear MUST price the recurring component from the frozen snapshot, and the priced line MUST inherit the fact's idempotency key +- **And** at most one priced recurring line per key MUST reach Billing — no second producer of the recurring line exists + +**28. Draft void** +- **Given** a subscription in `draft` that will never be activated +- **When** a `cancel` (void) is submitted +- **Then** the subscription MUST transition `draft → cancelled` through the normal commit path (audited, idempotent, `cancelMode = immediate`) +- **And** no Policy/OSS provisioning leg MUST run (nothing was provisioned) + +**29. Renewal collection during a pause window** +- **Given** an auto-renewing subscription with an open `collectionPaused` window covering the renewal collection +- **When** the renewal window arrives +- **Then** the term MUST extend per Contract, but the payment pre-check, grace entry, and dunning handoff MUST NOT run until the pause window ends +- **And** the deferred collection MUST run when the window ends, per the Billing artifact treatment + +## 13. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| AMS / OSS (tenant identity & hierarchy) | Tenant identity, `resourceTenantId` topology references, account/OrgTier context, delegation-proof backbone | `p1` | +| Catalog registry (Product & SKU) | Published `skuId`, `PlanTier` taxonomy, `CatalogVersion`, `catalogSubscriptionProductKey` for the overlap rule; the **products** gear, `gears/bss/products/docs/PRD.md` (vendored 2026-07-16) | `p1` | +| Pricing (Product Catalog) | Published `planId`, `PriceWindow` linkage, price snapshot refs, trial offers ([pricing PRD](../../pricing/docs/PRD.md)) | `p1` | +| Rating (evaluation core + pipeline) | Consumes composition read models + `(changeEffectiveAt, changeMode)`; owns proration math and usage slicing ([rating PRD](../../rating/docs/PRD.md)) | `p1` | +| Billing & Invoicing | Ingests recurring `BillableItem`s; posts immutable invoices; adjustments/credit/debit notes; dunning execution | `p1` | +| Contracts & Agreements | `Renewal` terms, grace ladder / regional templates, `PriceOverride` windows | `p1` | +| Policy Engine | Fail-closed allow/deny + `reasonCodes` for resource-affecting transitions | `p1` | +| OSS Provisioning | Provision/deprovision/pause execution confirmed by events | `p1` | +| Payments (PSP) | Payment pre-check + retry-exhaustion signals for the grace ladder; authorization at renewal/trial conversion | `p2` | +| Notifications / Comms | Delivery of renewal notices, trial-expiry and win-back hooks (triggers owned here) | `p2` | +| BSS Architecture Manifest | §4.3 primary; §4.1/§4.2/§4.4 contracts; §4.6 renewals; §2.1.3 identities; §6 gates | `p1` | + +## 14. Assumptions + +- SLA numbers in §7.1 are working baselines from the predecessor PRD pending the program NFR workshop; the workshop overrides on conflict. +- In this repository Plan&Price and Tariffs+Rating are the **pricing** and **rating** gears (originally vendored from upstream; upstream not maintained). The Product&SKU registry is **not yet authored in this repo** — a consumed dependency (§13; seam SUB-G1), referenced by name only. +- Contracts will own grace-ladder / regional-template terms as §6.5 assumes; the current upstream Contracts PRD does not yet define them (tracked in §15/§16). +- Design closes trial attribute/event naming, overlap **dimension** binding, and Payments/Billing integration payloads (PSP webhooks, dunning handoff) consistent with the §6.5 grace ladder. +- Trials remain representable without a `trial` status unless the BSS manifest enum is amended. +- PostgreSQL is sufficient for subscription/entitlement state at launch; re-evaluate at the 100K+/tenant scale target (Design). +- Eventual consistency between subscription state and downstream read models is acceptable — except the entitlement check surface, which follows the §7.1 propagation baseline. +- Payment authorization/capture arrives via the future Payments module; this gear only triggers requests and consumes outcome signals. + +## 15. Open Questions + +| **Question** | **Owner** | **Target Date** | **Answer** | **Date Answered** | +|--------------|-----------|-----------------|------------|-------------------| +| Is **`trial`** a `Subscription.status`? | Product / Architecture | — | **No** — trials use **attributes / `PlanLink` / contract+Catalog** on manifest statuses (§6.1). A manifest `trial` status would require manifest + Design change first. | 2026-05-12 | +| Trial **commercial pattern** (trial-only SKU vs attribute-only vs hybrid) | Product / Catalog | — | **Resolved:** **Catalog-first** trial definition + **Contract** legal/commercial clauses + **Subscription** evaluated attributes (§6.1). Attribute-only allowed only with **Contract**-recorded terms. | 2026-05-12 | +| Default cardinality: overlapping subscriptions allowed? | Product / Catalog | — | **Resolved:** default **one** `active` per **`(payerTenantId, catalogSubscriptionProductKey)`** unless Catalog/Contract sets **`maxConcurrentActive` > 1** or extra scope dimensions (§6.3). | 2026-05-12 | +| Failed renewal **grace** (duration, recurring posture, Contract SoR, suspension triggers) | Product / Contracts | — | **Resolved in PRD** — §6.5 grace policy (7-day default, paused next-term recurring, Contract SoR + evaluated fields, hybrid exit trigger). | 2026-05-12 | +| Legacy upstream PR #154 review items (canonical `refs`, HTTP header-name leakage, AC 7 "grace"-≠-status wording, proration ownership) | — | — | **Resolved / N/A.** All were addressed in this copy when the doc was brought here (canonical `refs`, header-name leakage §9.1, AC 7 wording, proration ownership §6.3); `gears-rust` is now canonical and upstream is not maintained, so there is no sync obligation. The one substantive item that remains a **live cross-PRD dependency** is Contracts not yet owning the grace/regional-template SoR — tracked as risk (§16, seam SUB-C1). | 2026-07-15 | +| `convertTrial` / `updateQuantity` / the SUB-D-08 set (`renew`, `unschedule`, `pauseCollection`, `resumeCollection`, `confirmAcceptance`, `extendTrial`) as `TransitionRequest.type` values + the scheduled-intent envelope (`cancelMode`, `resumeAt`) — manifest §4.3 alignment | Architecture / manifest owners | TBD | Proposed by §6.1 / §6.3 / §6.4 / §6.5 / §6.10 (this repo); needs manifest alignment like any type or envelope addition. | — | +| Transfer billing boundary (immediate vs next-cycle payer rebind; mid-period payer split) | Product / Billing | TBD | SUB-D-06 pins the ordering key; the collection-side boundary of a payer rebind is a Billing/Product call (design slice 07 defaults to next-cycle). | — | +| Brand overlay source discrepancy: rating PRD matches `brand` on Plan/SKU `brandId`, this PRD publishes the per-sale `brandId` (§6.2, AC 20) | Rating / Subscriptions | TBD | Pin with rating which source feeds step-4 brand matching (seam SUB-R5); AC 20 is not implementable while the two disagree. | — | +| Draft retention TTL (auto-void of abandoned drafts) | Product | TBD | SUB-D-11 adds the `draft → cancelled` edge; whether a platform TTL submits the void automatically is a Product knob. | — | +| Repeat-trial eligibility (serial re-trials after cancel) | Product / Pricing | TBD | No owner today; the overlap rule blocks only concurrent duplicates. Candidate: pricing trial-offer eligibility window or Contract clause. | — | +| Quota-crossing propagation bound (usage → check-state lag; overrun exposure) | OSS / Rating / Product | TBD | §6.9 fixes the check-state semantics; the end-to-end lag budget (rating pipeline + fold-in) has no NFR yet — hard limits are only as fast as that path. | — | +| Entitlement check staleness budget default (SUB-D-10: last-known-good ≤ 60s on projection outage, then fail-closed) | Product / OSS | TBD | The degraded-mode shape is decided (SUB-D-10); the budget value is a Product/OSS knob to confirm. | — | +| Ramp authoring in Contracts (committed multi-step schedules; atomic multi-action orders) | Contracts / Design | TBD | SUB-D-04: Contracts authors the committed ramp; Subscriptions executes generated scheduled intents (§6.3); Contracts PRD follow-up. | — | +| Acceptance confirmation flow (who confirms, evidence shape) | Product / Design | TBD | SUB-D-05 fixes the instants as attributes (§6.1); the confirmation operation flow is Design. | — | +| Quota exhausted mid-request: graceful degradation vs hard block | OSS / Design | TBD | §6.9 fixes the check-state semantics; the mid-request instant is OSS/Design. | — | +| Trial extension approval policy (automatic / manual / threshold-based) | Product | TBD | §6.10 requires approval-gating; the policy itself is open. | — | +| Notice intervals & grace durations: partner-configurable vs platform standard | Product / Contracts | TBD | Working default: platform defaults (30/14/7/1 notices; 7-day grace) with Contract/template override within Legal bounds. | — | +| Pause mechanics (pause-day limits, resume proration) | Product / Billing | TBD | Posture decided (SUB-D-03: `collectionPaused` subscription attribute, collection-scoped — §6.4); the billing-cycle mechanics remain open. | — | +| Maximum subscription term length | Product / Contracts | TBD | Impacts Contract templates and renewal windows. | — | +| Entitlement inheritance for bundled subscriptions (parent/child aggregation) | Product / Design | TBD | Bundling lifecycle sync is `p3` scope; aggregation rules undefined. | — | +| Proration credit application policy (immediate credit / next invoice / refund) | Finance / Billing | TBD | `creditOnDowngrade` is published by pricing; the application policy is Billing/Finance. | — | +| Committed usage: Subscriptions or Contracts? | — | — | **Resolved**: commitment pools = Contracts SoR, true-up = rating (T-D-14); this gear keeps hooks only (§2.2). | 2026-07-15 | +| Which subscription models are supported (fixed-term, evergreen, hybrid, prepaid)? | — | — | **Resolved**: all four via composition — terms on Contract, hybrid via pricing hybrid plans, prepaid via the pricing D-43 grant + Billing execution (GA-gated). | 2026-07-15 | + +## 16. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Suspension vs billing alignment left implicit (manifest risk note) | Revenue leak or double-charge during suspension | §6.4 requires the posture to be explicit product policy in subscription attributes + contract clauses | +| Evergreen renewals / notice periods under-specified (manifest risk note) | Non-compliant auto-renewals; disputes | Contract templates MUST define notice/opt-out behavior (§6.5); Legal bounds on grace overrides | +| Contracts PRD does not yet define grace ladder / regional templates | §6.5 assumes a Contracts SoR that upstream has not authored yet | Track as upstream follow-up (§15); until then the 7-day platform default governs | +| Proration ownership conflicts with older upstream Rating/Billing PRD wording | Ambiguous calculation authority ("Billing preview API") | §6.3 ownership split is normative here and matches the local rating gear; preview owner named in Design (§11 wording already neutral) | +| Normative upstream reference to Rating (VHP-810) not yet merged — Product&SKU / Plan&Price / Tariffs landed in upstream `main` post-review | Broken traceability upstream for the one remaining link | Vendored rating gear covers it locally; the upstream unresolvable-links blocker on PR #154 narrows to rating-engine (§15) | +| Dunning/PSP integration details deferred to Design | Grace ladder not executable end-to-end at launch | §6.5 fixes product defaults; Design encodes timers + Payments signals before implementation | +| Entitlement check hot path (p95 < 100ms at 100K+/tenant) | Latency breach blocks real-time OSS enforcement | Cache-first check surface with the §7.1 propagation baseline; load test before GA | +| Notifications integration missing at launch | Renewal notices / opt-out windows silently missed | §6.5 triggers are normative; Notifications delivery is a tracked `p2` dependency (§13) | + +## 17. Reference Materials + +| **Material** | **Link** | **Comments** | +|--------------|----------|--------------| +| BSS Architecture Manifest | `docs/bss/manifest/vz-arch-manifest-bss-only.md` (upstream) | §4.3 primary; §4.6 renewals; §6 gates | +| Origin (provenance) | `bitbucket.org/virtuozzocore/vhp-architecture` PR #154, branch VHP-806, commit `4faef39652d0` | Originally vendored 2026-07-15; upstream **not maintained** — `gears-rust` is canonical | +| Subscriptions & Entitlements (predecessor module PRD) | `docs/bss/prd/PRD-subscriptions-entitlements-202601120119/` (upstream, legacy) | **Absorbed into this PRD (2026-07-15)** — §2.2 section map; the upstream copy is legacy provenance (not maintained) | +| Product & SKU Management (Catalog registry §4.1) | **products** gear — `gears/bss/products/docs/PRD.md` (vendored 2026-07-16 from PR #4177) | SoR for Product/SKU/Category/Attribute/`PlanTier`/`CatalogVersion` | +| Plan & Price Modeling (Catalog §4.1) | [pricing PRD](../../pricing/docs/PRD.md) (vendored gear) | Owns `Plan`/`Price`/`PriceWindow` linkage that `PlanLink` resolves | +| Tariffs + Rating (§4.2) | [rating PRD](../../rating/docs/PRD.md) (vendored gear, consolidated per rating ADR-0002) | Owns proration math, override hierarchy, coupons, FX; consumes `(changeEffectiveAt, changeMode)`; shared ordering key | +| Contracts and Agreements (§4.6) | `docs/bss/prd/PRD-contracts-agreements-202601120119/` (upstream) | SoR for renewal terms; grace ladder / regional templates are a tracked follow-up (§16) | +| Billing — Ledger & Balances (§4.4) | `docs/bss/prd/PRD-billing-ledger-balances-202604041200/` (upstream) | Posted-invoice immutability, adjustments, rounding authority | +| Billing module (§4.4) | `docs/bss/prd/PRD-billing-module-202601120119/` (upstream) | Recurring ingestion, dunning execution | +| Vendor gap analysis (Stripe & Zuora) | [STRIPE-ZUORA-GAP-ANALYSIS.md](./STRIPE-ZUORA-GAP-ANALYSIS.md) | Supersedes the predecessor's Market Intelligence section; open gaps G-1/G-3…G-6 | + +### 17.1 Reconciliation Framework (operational appendix) + +Daily/weekly cross-checks carried from the predecessor; concrete owners and schedules are confirmed in Design: + +| **Check** | **Source A** | **Source B** | **Frequency** | **Outcome** | +|-----------|--------------|--------------|---------------|-------------| +| Charge coverage | Subscriptions with `nextChargeDate` passed | Billing recurring postings | Daily 02:00 | Identify missed charges or posting failures | +| Entitlement sync | Plan grant-set templates | Entitlement assignments | Daily 04:00 | Detect entitlement drift or sync failures | +| Billing alignment | Subscription charge schedules | Billing ledger entries | Daily 06:00 | Verify charge amounts and periods match | +| Trial conversion | Expired trials with payment method | Converted subscriptions | Daily 08:00 | Track conversion success rate; identify failures | +| Renewal processing | Subscriptions at term end | Renewed subscriptions | Daily after renewal window | Ensure all renewals processed | +| Catalog sync | Subscription plan references | Catalog plan definitions | Weekly | Detect orphaned plans or version mismatches | diff --git a/gears/bss/subscriptions/docs/SEAMS.md b/gears/bss/subscriptions/docs/SEAMS.md new file mode 100644 index 000000000..6d045a271 --- /dev/null +++ b/gears/bss/subscriptions/docs/SEAMS.md @@ -0,0 +1,210 @@ + + + +# Subscriptions — Cross-Gear Seam Map + +> Cross-gear seam analysis produced before the Subscriptions design, against the mature +> **pricing** and **rating** design sets (`gears/bss/pricing/docs/`, `gears/bss/rating/docs/`) +> and the subscription lifecycle PRD ([`PRD.md`](./PRD.md)). Subscriptions is the **System of +> Record** for the subscription commercial aggregate: the lifecycle state machine, effective-dated +> composition (`PlanLink`/`AddOn`), the change **boundary/mode**, renewal execution, entitlement +> assignment + point-of-use check state, and multi-tenant ownership. It is **not** an authoring +> catalog and it computes **no** money — pricing owns the catalog, rating owns evaluation and +> proration math, Billing owns posting. +> +> Unlike the rating⇄pricing map (a single 1:1 complementarity), the subscription aggregate sits at +> the centre of the BSS lifecycle and its seams fan out to **many** neighbours. This map is +> therefore organised **by neighbour** (A–H), each seam carrying a stable `SUB-` id. +> +> **Verdict legend:** `SUB-authors` = Subscriptions is the SoR the neighbour consumes (no +> Subscriptions change beyond exposing the contract); `SUB-adopts` = Subscriptions adopts a +> neighbour-side fact verbatim (no neighbour change); `Joint` = a shared contract needs a +> co-decision; `Neighbour-extends` = a neighbour must expose/extend something; `Product` = a +> launch-scope / commercial call; `ALIGNED` = the counterpart contract is already written on the +> other side, no action beyond citing it. +> +> Severity: `CRIT` (breaks lifecycle/billing correctness), `HIGH`, `MED`, `LOW`. Line refs: `S:` = +> this gear's [`PRD.md`](./PRD.md); neighbour refs carry their path. The twelve autonomous decisions +> `SUB-D-01…12` live in [`DECISIONS.md`](./DECISIONS.md); the vendor gaps `G-1…G-6` in +> [`STRIPE-ZUORA-GAP-ANALYSIS.md`](./STRIPE-ZUORA-GAP-ANALYSIS.md). + +--- + +## A. Rating (evaluation core + pipeline) + +> The rating gear consumes subscription composition and the change boundary, and owns all +> commercial math. The counterpart contract is **already written on the rating side** +> (`gears/bss/rating/docs/PRD.md` §9.2 "Subscriptions input contract"; proration split in rating +> §6.11 / design slice `09-period-plan-change`). Ordering shares one key +> `(resourceTenantId, subscriptionId)` (S:803, rating `11-consumer-contracts`). + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-R1** | CRIT | **Joint (field alignment open; was ALIGNED)** | **Composition read-model + change boundary.** Subscriptions exposes effective `PlanLink`/`AddOn` intervals, `PlanTier` @ `t`, the **active plan phase** @ `t`, the plan-change `(changeEffectiveAt, changeMode)`, the **committed seat quantity @ `t`** (effective-dated, SUB-D-02), the **`priceEligibility` inputs** (`activatedAt`, bound `cohort`), and the per-sale `brandId` context; rating consumes them and slices usage + prorates at the same boundary (S:1034; rating PRD §9.2, rating `design/09`). **Downgraded from ALIGNED (2026-07-15 review):** the rating counterpart names the seat count and `priceEligibility` inputs that this side's contract omitted — the two field lists MUST be mirror-reconciled before Design lock. Ordering rides the **pinned `orderingTenantId`** (immutable across transfers — SUB-D-06). **WHEN vs MATH split is binding**: Subscriptions owns the boundary/mode, rating owns proration day-count and tier-`Q`/commitment carry-vs-reset (S:590). No Subscriptions-side math. Design implements the exposure surface (slice `09-consumer-contracts`). | +| **SUB-R2** | MED | **ALIGNED (SUB-authors)** | **Snapshot `(currency, region)` segment.** Subscriptions freezes the `(currency, region)` binding at activation into the composite `pricingSnapshotRef`; rating is the composition SoR that seals the ref (S:532; rating SEAMS **S1**). **Open to confirm at design:** after SUB-D-02/05, the **seat-count provenance** (SUB-R3) and the **activation date-trio** (SUB-C4) are **not** new snapshot segments — they ride events/read-models, not the pinned pricing ref. Design must state this explicitly so no fourth Subscriptions segment silently appears. | +| **SUB-R3** | HIGH | **Joint** | **Seat-count provenance + mid-period seat boundary.** Pricing `quantitySource = subscription_seat_count` (D-18) makes this gear the seat supplier; the count rating reads MUST originate **only from committed `updateQuantity` transitions** (SUB-D-02, S:620) — never an untyped attribute edit — and is stored **effective-dated** so `quantity @ t` resolves for replay (2026-07-15 review fix; a single mutable value cannot serve the replay contract). **Open (mirrors rating's "seat-change boundary transport", rating `design/09` §4.3):** a mid-period seat change is transported as a **Subscriptions-driven change boundary** (default) that rating prorates — *not* Subscriptions-side proration. Pin the default with rating at design; increases MAY be immediate, decreases default `next-cycle` (SUB-D-02). | +| **SUB-R4** | HIGH | **Joint** | **Phase boundary = change boundary.** `convertTrial` / scheduled trial conversion advance the plan **phase** boundary; rating consumes that instant like any `changeEffectiveAt` (S:919). Phase axis is a `phase_id` (pricing D-19); Subscriptions is the **phase-structure SoR** and resolves the active phase @ `t` for rating (S:460). Confirm with rating that the phase-boundary instant travels on the same `(changeEffectiveAt, changeMode)` channel as a plan change (no second boundary vocabulary). A **trial extension** moves the phase boundary and MUST emit the boundary-move on this same channel (slice 06). | +| **SUB-R5** | MED | **Joint (discrepancy)** | **Brand context source.** This PRD publishes the **per-sale `brandId`** as a Subscriptions attribute in the evaluation context (S: §6.2, AC 20); the rating PRD's step-4 overlay scope matches `brand` against **Plan/SKU `brandId` @ `t`** (rating PRD §6 step 4, §17.4) — two different sources. Pin with rating which one feeds brand-scoped overlay matching (per-sale storefront attribution only this gear can supply vs catalog-declared membership); AC 20 is not implementable while they disagree. Tracked in PRD §15. | +| **SUB-R6** | HIGH | **Joint (SUB-D-07)** | **Recurring pricing enrichment.** Subscriptions cuts the **money-free recurring period fact** (`(subscriptionId, billing period)` key, traceability tuple, `pricingSnapshotRef`, pause/intent posture); **rating prices** the recurring component from the frozen snapshot (flat / per-unit × quantity / hybrid recurring line) and the priced line **inherits the fact's key** before Billing posts (SUB-D-07, S: §6.8, AC 27). Removes the double-producer collision with rating's step-9 recurring lines. Needs the rating counterpart contract + joint fixture before Design lock. | + +## B. Pricing (Product Catalog) + +> Pricing is the authoring SoR for `Plan`/`Price`/`PriceWindow`/`PriceOverlay`/`CatalogVersion` and +> publish governance; Subscriptions resolves published catalog keys and adopts its consumer +> contracts. Most seams are **adopt-verbatim** against the frozen pricing consumer contract +> (`pricing/docs/design/06-consumer-contracts.md`). + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-P1** | HIGH | **SUB-adopts** | **Plan-change classification contract.** Pricing publishes `allowedChangeTargets` / `comparabilityRank` / the boundary class in the frozen consumer contract (pricing `design/06`); **Subscriptions classifies** an upgrade/downgrade/cross and enforces the boundary — it does not re-derive comparability. **Cross-currency / cross-region / cross-frequency = cancel+new**, not an in-place change (pricing §15 cross-boundary sign-off; S:610 overlap). Adopt the pricing classification verbatim; Subscriptions owns only the WHEN. | +| **SUB-P2** | HIGH | **SUB-adopts** | **Phase → grant-set map.** Entitlement assignment reads the plan's **published grant set**, including the **per-phase map** where the plan is phased (pricing **D-41**; S:867). `convertsToPhaseId` drives end-of-trial conversion (S:909). Catalog authors the templates; this gear resolves + materialises per subscription. Adopt the phase/grant contract; author nothing catalog-side. | +| **SUB-P3** | MED | **ALIGNED (SUB-adopts)** | **Trial sellable definition.** Catalog is authoritative for the trial offer — trial plan/SKU, promotional `PriceWindow`, or a leading trial **phase** (S:470, S:899). Subscriptions persists **evaluated** trial state (attributes + `PlanLink`/snapshot pointers), never a `trial` status (S:458). Aligned; attribute/event naming closes at design. | +| **SUB-P4** | MED | **Product (deferred)** | **Prepaid credit grant.** The prepaid credit **definition** is pricing **D-43**; the balance/drawdown is **Billing/Rating**, GA-gated (S:182). Subscriptions keeps **subscription-side hooks only** — it neither defines nor draws down the wallet. See the Billing-facing drawdown line **SUB-B4**. No launch dependency for the core lifecycle. | +| **SUB-P5** | MED | **SUB-adopts** | **Sellability / publish gate.** `PlanLink`/`AddOn` resolve only against **published** plans that pass the pricing sellability gate (pricing `design/07`; S:150). A draft or `not_sellable_ga` plan MUST fail the `create`/`changePlan` guard fail-closed. Adopt the gate as a precondition; the overlap **key** itself is registry-owned (**SUB-G1**). | + +## C. Contracts & Agreements + +> Contracts is the SoR for signed terms, renewal, grace, regional templates, ramps, commitment +> pools, and booking dates. **Risk:** the upstream Contracts PRD does **not yet author** several of +> these (S:1325, §16) — until it does, the platform defaults in the Subscriptions PRD govern and the +> obligation is tracked as a cross-PRD follow-up. + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-C1** | HIGH | **Joint (RISK — upstream unauthored)** | **Renewal / grace ladder / regional templates.** §6.5 assumes Contracts is the SoR for `Renewal` (`autoRenew`, term windows, notice), grace length/ladder, and regional templates (S:674, S:718). Upstream Contracts PRD has not authored them (S:1360). **Until authored:** the platform defaults govern — **7-day grace**, **30/14/7/1 notices**, hybrid exit trigger (S:722–725). Subscriptions stores **evaluated fields** at renewal-evaluation time for replay. Cross-PRD obligation on Contracts to author the SoR; Subscriptions consumes via events + read models. | +| **SUB-C2** | MED | **Joint** | **Ramps (committed multi-step schedules).** Contracts authors the committed ramp; Subscriptions **executes** it as a sequence of scheduled `changePlan`/`updateQuantity` intents (**SUB-D-04**, S:630). No native `SubscriptionSchedule` aggregate at launch. **Open:** atomic multi-action submission (Zuora-Orders-style) is a Contracts/Design follow-up (S:1342). Depends on the SUB-D-01/02 intent envelopes. | +| **SUB-C3** | MED | **SUB-adopts (owner = Contracts)** | **Commitment pools.** Committed-usage pools are **Contracts SoR**, true-up is **rating** (rating **T-D-14**, rating SEAMS **M8**; S:181). This gear keeps **subscription-side hooks only** — it neither owns the pool balance nor computes the true-up. Adopt the owner split; expose the subscription linkage. | +| **SUB-C4** | MED | **Joint** | **Activation date-trio + acceptance.** `contractEffectiveAt` (booking) is **referenced from the Contract** — booking semantics stay Contracts/Finance SoR; `serviceActivatedAt` is stamped at the `activate` commit here; `customerAcceptedAt` is stamped by an optional **acceptance confirmation** where Contract clauses require it, else = service activation (**SUB-D-05**, S:500). **No new statuses.** All three ride lifecycle events + ASC hooks. **Open:** the confirmation-flow shape (who confirms, evidence) is design (S:1343). | +| **SUB-C5** | MED | **SUB-adopts** | **`PriceOverride` windows.** Contracts supplies negotiated override windows consumed via events/read models; in rating these are the **step-5 contract overlay** (rating `04-overlays-precedence`, precedence Contract > Partner PriceOverlay > Catalog base). Subscriptions references the override binding for composition/renewal; it does not evaluate the override. | + +## D. Billing & Invoicing + +> Billing ingests recurring `BillableItem`s, posts immutable invoices, executes +> adjustments/credit/debit notes and dunning, and owns floor/cap + rounding. This section is the +> single **Billing-facing** surface: it folds in the still-open **pricing** gap **G-4** (prepaid +> drawdown / tax placement) as a joint line, since that too resolves at the Billing boundary. + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-B1** | HIGH | **ALIGNED (SUB-authors)** | **Recurring idempotency + no-retro-edit + traceability.** `BillableItemCreated(kind=recurring)` idempotent per `(subscriptionId, billing period)` (S:815); posted invoice lines never rewritten — corrections flow as new billable/adjustment artifacts (S:825); every item traces to `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef` (S:835). What crosses this seam is the **money-free period fact**; the priced line arrives at Billing via the rating enrichment (**SUB-R6**, SUB-D-07). These are manifest §4.3/§4.4 invariants shared with Billing; design exposes the handoff payload (slice `08-events-billing`, `09-consumer-contracts`). | +| **SUB-B2** | MED | **Joint** | **`collectionPaused` artifact treatment.** The billing-only pause (service running, collection suppressed/deferred) is a Subscriptions **attribute posture on `active`** (**SUB-D-03**, S:662); **Billing chooses the artifact treatment** per policy (suppress vs defer) — the period fact is still emitted, marked `collectionPaused`, so Billing has the artifact to treat (AC 24 "not posted" holds). Renewal **collection** inside the window is deferred with it (payment pre-check/grace/dunning suspended; term extension continues — **SUB-D-12**, AC 29). **Open (§15):** pause-day limits and resume-proration mechanics — Product/Billing (S:1347). Finance may still prefer a Billing-side AR hold; the aggregate-posture shape keeps the audit trail on the subscription either way. | +| **SUB-B3** | MED | **SUB-adopts (owner = Billing)** | **Period floor/cap + rounding execution.** Floor/cap and rounding are **Billing-executed** (rating §17.1 post-step-9; rating `09-period-plan-change` `PeriodFloorCapObligation`). Subscriptions neither floors nor rounds; it coordinates the artifacts only. Adopt the owner. | +| **SUB-B4** | MED | **Joint (pricing G-4)** | **Prepaid drawdown + tax placement.** The pricing gap **G-4** (`STRIPE-GAP-ANALYSIS.md`, still open) places prepaid-credit **drawdown** and **tax** at the Billing boundary; the pricing D-43 grant (**SUB-P4**) is defined but Billing/Rating execute the balance. Subscriptions supplies the subscription-side hooks (which subscription, which grant reference) only. Resolve jointly with pricing + Billing before prepaid GA. | +| **SUB-B5** | MED | **SUB-authors** | **Dunning handoff.** Post-renewal billing failure hands off to **dunning** (Billing/Payments §4.4–4.5); the §6.5 grace rules and triggers apply (S:707). Subscriptions emits the failure/grace signals and the audit trail; **dunning execution + PSP webhook payloads are Billing/Payments + Design** (S:1058, S:1326). | +| **SUB-B6** | MED | **Neighbour-extends (Billing)** | **Posted-period watermark for the backdating guard.** The §6.3 backdating guard ("reject a boundary inside an already-posted invoice period", AC 6) needs to *know* what is posted — Billing MUST expose a per-subscription **`billedThroughAt`** watermark (read model/event) that Subscriptions consumes fail-closed (unknown watermark ⇒ treat as posted, reject). Identified by the 2026-07-15 design review: without this the guard has no data source (design slice 03 §4.6). | + +## E. Policy Engine & OSS Provisioning + +> Every resource-affecting transition is fail-closed gated by Policy before commit; OSS executes +> provisioning; entitlement **enforcement** executes in OSS while the **decision state** stays here. + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-E1** | CRIT | **ALIGNED (SUB-adopts)** | **Fail-closed Policy gate.** Every resource-affecting transition passes a pre-commit allow/deny + `reasonCodes`; on deny **or unavailability** the state MUST NOT change (S:450, S:1046; AC 1). Manifest §6. Aligned; design maps the gate call + reason surfacing. | +| **SUB-E2** | HIGH | **SUB-authors** | **OSS provisioning confirmation.** `activate`/`suspend`/`resume`/`cancel` coordinate provision/deprovision/pause **work orders confirmed by events**; **BSS never mutates OSS resource topology directly** (S:272, S:1052). Subscriptions issues the intent + consumes the confirmation; it does not touch OSS state. | +| **SUB-E3** | HIGH | **Joint** | **Entitlement enforcement split.** Subscriptions serves the **point-of-use check decision state** (feature flag, quota remaining, limit state) at **p95 < 100ms** (S:877); **OSS enforces** (allow/block/degrade) — this gear never executes enforcement. **Open (§15):** mid-request behaviour at the exhaustion instant (graceful degradation vs hard block) is OSS/Design (S:887, S:1344). Pin the check-state ↔ enforcement contract with OSS at design. | + +## F. Payments & Notifications + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-F1** | MED | **SUB-adopts** | **Payments signals.** Payment **pre-check** outcomes + **retry-exhaustion** declarations feed the renewal/grace ladder (S:1056); authorization is requested at renewal / trial conversion. **PSP capture + webhook behaviour are out of scope** (S:278, §4.5) — this gear only triggers requests and consumes outcome signals. The grace exit trigger (ii) — "no further automated retries" — is a Payments declaration (S:725). | +| **SUB-F2** | LOW | **SUB-authors** | **Notification triggers.** Renewal-notice **triggers + intervals** (30/14/7/1) and the trial-expiry **win-back hook** are owned here (S:694, S:929); **delivery channels are Notifications/Comms** (out of scope §5.2). Subscriptions emits the trigger events; it never delivers. | + +## G. Catalog Registry (Product & SKU) + +> The registry carves Product/SKU/Category/Attribute/`PlanTier` taxonomy/`CatalogVersion` out of the +> manifest. It is now **vendored on this branch** as the **products** gear (`gears/bss/products/docs/PRD.md`, 2026-07-16, from upstream **PR #4177**; merged in +> vhp-architecture `main`, tracked in [product-sku-prd-location]). Seams are tracked **prospectively**. + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-G1** | HIGH | **Joint (prospective, PR #4177)** | **`overlapScopeKey` binds to a registry key.** The overlap cardinality rule defaults to at most **one `active`** per `(payerTenantId, catalogSubscriptionProductKey)` (S:610). `catalogSubscriptionProductKey` is the **registry-owned** stable key for the sellable subscription product/family; **Design binds the stored field to a published SKU/product key**. Engage on PR #4177 before merge so the key shape is agreed; `maxConcurrentActive` override may come from Catalog **or** Contract. | +| **SUB-G2** | MED | **SUB-adopts** | **Published `skuId` / `PlanTier` / `CatalogVersion`.** Subscriptions reads **published** registry facts only (S:242); `PlanLink`/overlap key bind to published keys; effective `PlanTier` MUST be derivable @ event time (S:540). Read-only consumer; never re-authors taxonomy. | + +## H. Naming / cross-cutting + +| # | Sev | Verdict | Seam | +|---|-----|---------|------| +| **SUB-N1** | LOW | **Joint (manifest alignment)** | **New `TransitionRequest.type` values + scheduled-intent envelope.** `updateQuantity` (SUB-D-02), `convertTrial` (G-2), and the SUB-D-08 completion set (`renew`, `unschedule`, `pauseCollection`, `resumeCollection`, `confirmAcceptance`, `extendTrial`) extend the manifest §4.3 `type` list; the scheduled-intent envelope (`cancelMode`, `resumeAt`) extends the change vocabulary (S:480, S:490). Manifest alignment is tracked in §15; **not re-opened here** — the seam records that downstream consumers keying on `TransitionRequest.type` must not fail-closed on the new values before the manifest lands. | +| **SUB-N2** | LOW | **Naming** | **Gear-name pinning.** "Pricing (Product Catalog)", "Rating (evaluation core + pipeline)", "Catalog registry (Product & SKU)" are pinned in §2.1 (S:156). Downstream references MUST use these canonical names; no drift to legacy "Tariffs"/"PLAL"/"Price Book". | + +--- + +## Ownership matrix (contested / adjacent responsibilities) + +| Responsibility | Owner | Seam | +|---|---|---| +| Subscription lifecycle state machine + terminality | **Subscriptions** (SoR) | SUB-R1 | +| Change **boundary/mode** (`changeEffectiveAt`, `changeMode`) | **Subscriptions** | SUB-R1, SUB-R3 | +| Proration / plan-change **math** | **Rating** (evaluates); Subscriptions sets the boundary | SUB-R1 | +| Usage slicing at the change boundary | **Rating** (pipeline) | SUB-R1 | +| `(currency, region)` snapshot segment | **Subscriptions** @ activation; rating seals the ref | SUB-R2 | +| Seat-count provenance (effective-dated, `quantity @ t`) | **Subscriptions** (committed `updateQuantity` only); rating consumes frozen | SUB-R3 | +| Ordering/partition key (`orderingTenantId`, pinned at creation) | **Subscriptions** — immutable across transfers | SUB-R1 | +| Recurring period **cut** (anchor, pauses, intents, idempotency key) | **Subscriptions**; rating **prices** the fact; Billing posts | SUB-R6, SUB-B1 | +| Brand context source for overlay matching | **Contested** — per-sale (here) vs Plan/SKU (rating); pin at design | SUB-R5 | +| Posted-period watermark (`billedThroughAt`) | **Billing** exposes; Subscriptions enforces the backdating guard | SUB-B6 | +| Plan phase **structure** | **Subscriptions** (SoR); rating resolves @ `t` | SUB-R4 | +| Plan-change **classification** (comparability/targets) | **Pricing** (publishes); Subscriptions enforces | SUB-P1 | +| Entitlement grant-set **templates** (incl. per-phase) | **Pricing** (authors); Subscriptions assigns | SUB-P2 | +| Entitlement **assignment** per subscription | **Subscriptions** | SUB-P2 | +| Entitlement point-of-use **decision state** | **Subscriptions** (serves); OSS enforces | SUB-E3 | +| Trial sellable **definition** | **Pricing/Catalog**; Contract legal clauses | SUB-P3, SUB-C1 | +| Prepaid credit **definition** / **drawdown** | **Pricing** (D-43 def) / **Billing+Rating** (balance) | SUB-P4, SUB-B4 | +| Renewal / grace / regional templates SoR | **Contracts** (unauthored upstream → platform default) | SUB-C1 | +| Ramp authoring / execution | **Contracts** (authors) / **Subscriptions** (executes intents) | SUB-C2 | +| Commitment pools | **Contracts** SoR; true-up = Rating; Subscriptions hooks only | SUB-C3 | +| Booking date (`contractEffectiveAt`) | **Contracts/Finance**; Subscriptions references | SUB-C4 | +| Recurring idempotency + posted-invoice immutability | **Subscriptions** (keys/emits) + **Billing** (posts) | SUB-B1 | +| `collectionPaused` posture / artifact treatment | **Subscriptions** (posture) / **Billing** (artifact) | SUB-B2 | +| Period floor/cap + rounding | **Billing** | SUB-B3 | +| Dunning execution + PSP webhooks | **Billing/Payments** | SUB-B5, SUB-F1 | +| Fail-closed transition gate | **Policy Engine** | SUB-E1 | +| OSS resource topology | **OSS** (BSS never mutates directly) | SUB-E2 | +| Notice/win-back **delivery** | **Notifications/Comms** | SUB-F2 | +| `catalogSubscriptionProductKey` / published SKU/`CatalogVersion` | **Registry** (Product & SKU) | SUB-G1, SUB-G2 | + +--- + +## Decisions register (to close before Design lock) + +**Resolved on this gear (autonomous, flagged for veto — [`DECISIONS.md`](./DECISIONS.md)):** +- **SUB-D-01** — scheduled lifecycle intents (`cancelMode`, `resumeAt`; pending intents suppress renewal/next-term recurring). Seams SUB-C2, SUB-B2. +- **SUB-D-02** — `updateQuantity` first-class transition with the change envelope; up/down asymmetry. Seams SUB-R3, SUB-P1. +- **SUB-D-03** — `collectionPaused` posture on `active`. Seam SUB-B2. +- **SUB-D-04** — ramps: Contracts authors, Subscriptions executes scheduled intents. Seam SUB-C2. +- **SUB-D-05** — activation date-trio as attributes; no new statuses. Seam SUB-C4. +- **SUB-D-06** — ordering tenant pinned at creation; transfers never rebind the ordering/partition key. Seam SUB-R1. +- **SUB-D-07** — recurring split: money-free period fact here, rating prices, Billing posts. Seams SUB-R6, SUB-B1. +- **SUB-D-08** — mutation-type inventory completed (`renew`, `unschedule`, `pauseCollection`/`resumeCollection`, `confirmAcceptance`, `extendTrial`). Seam SUB-N1. +- **SUB-D-09** — secondary producer-event inventory named in design slice 08. Seams SUB-R1, SUB-B1. +- **SUB-D-10** — entitlement check surface: bounded-staleness degraded mode. Seam SUB-E3. +- **SUB-D-11** — `draft → cancelled` (void) edge. (Status machine; no cross-gear seam.) +- **SUB-D-12** — `collectionPaused` defers renewal collection (pre-check/grace/dunning), not term extension. Seam SUB-B2. + +**Aligned (counterpart written; no action beyond citing):** +- SUB-R2 (rating SEAMS S1), SUB-P3, SUB-B1, SUB-E1. + +**Joint / cross-PRD obligations still open (engage the owner before Design lock):** +- **SUB-C1** — Contracts must author the renewal/grace/regional-template SoR (upstream PRD unauthored; §16 risk). Highest cross-PRD risk. +- **SUB-R1** — mirror-reconcile the read-model field lists with rating PRD §9.2 (seat quantity @ `t`, `priceEligibility` inputs; downgraded from ALIGNED by the 2026-07-15 review). +- **SUB-R3** — mid-period seat-change boundary transport (default: change-boundary) — pin with rating. +- **SUB-R4** — phase-boundary instant on the shared `(changeEffectiveAt, changeMode)` channel (incl. trial extension moves) — confirm with rating. +- **SUB-R5** — brand context source for overlay matching (per-sale vs Plan/SKU) — pin with rating; AC 20 blocked until resolved. +- **SUB-R6** — recurring pricing enrichment (SUB-D-07): rating counterpart contract + joint fixture. +- **SUB-B6** — Billing to expose the `billedThroughAt` posted-period watermark for the backdating guard. +- **SUB-E3** — check-state ↔ OSS enforcement contract + quota mid-request instant — pin with OSS; staleness-budget default (SUB-D-10) to confirm. +- **SUB-B2 / SUB-B4** — pause-day limits + resume proration (Product/Billing); prepaid drawdown/tax placement (pricing G-4) — pin with Billing. +- **SUB-C2** — atomic multi-action ramp submission — Contracts/Design follow-up. +- **SUB-C4** — acceptance-confirmation flow shape — Design. +- **SUB-G1** — `catalogSubscriptionProductKey` shape — engage PR #4177 before merge. + +**Manifest alignment (tracked §15, not a design blocker):** +- **SUB-N1** — `updateQuantity` / `convertTrial` `TransitionRequest.type` values + `cancelMode`/`resumeAt` envelope await manifest §4.3 alignment. + +**Propagation status:** the SUB-D-01…12 decisions are propagated into [`PRD.md`](./PRD.md) +(§5.1/§6/§12/§15) and [`DECISIONS.md`](./DECISIONS.md); vendor gaps G-1…G-6 are all processed +([`STRIPE-ZUORA-GAP-ANALYSIS.md`](./STRIPE-ZUORA-GAP-ANALYSIS.md)). This seam map is the input to +the design set — each slice implements the Subscriptions side of the seams listed for it in +[`DESIGN.md`](./DESIGN.md) §1.3. diff --git a/gears/bss/subscriptions/docs/STRIPE-ZUORA-GAP-ANALYSIS.md b/gears/bss/subscriptions/docs/STRIPE-ZUORA-GAP-ANALYSIS.md new file mode 100644 index 000000000..5bfa18fc6 --- /dev/null +++ b/gears/bss/subscriptions/docs/STRIPE-ZUORA-GAP-ANALYSIS.md @@ -0,0 +1,340 @@ + + + +# Subscriptions — Stripe & Zuora Gap Analysis + + + +- [1. Purpose & method](#1-purpose--method) +- [2. Scope boundary — vendor features that are NOT ours](#2-scope-boundary--vendor-features-that-are-not-ours) +- [3. Coverage map — vendor model vs what we have](#3-coverage-map--vendor-model-vs-what-we-have) + - [3.1 Status mapping](#31-status-mapping) + - [3.2 Feature coverage (HAVE)](#32-feature-coverage-have) +- [4. Already deferred or consciously split, with vendor equivalents](#4-already-deferred-or-consciously-split-with-vendor-equivalents) +- [5. Gaps worth acting on](#5-gaps-worth-acting-on) + - [G-1. Scheduled lifecycle intents (cancel at period end / at date; scheduled resume)](#g-1-scheduled-lifecycle-intents-cancel-at-period-end--at-date-scheduled-resume) + - [G-2. First-class early trial conversion](#g-2-first-class-early-trial-conversion) + - [G-3. Seat / quantity change as a lifecycle transition](#g-3-seat--quantity-change-as-a-lifecycle-transition) + - [G-4. Billing-only pause (collection paused, service running)](#g-4-billing-only-pause-collection-paused-service-running) + - [G-5. Committed multi-step schedules (ramps) and atomic multi-action orders](#g-5-committed-multi-step-schedules-ramps-and-atomic-multi-action-orders) + - [G-6. Activation date trio (booking / service / acceptance)](#g-6-activation-date-trio-booking--service--acceptance) +- [6. Recommendation summary](#6-recommendation-summary) + + + +## 1. Purpose & method + +A comparison of the vendored **Subscriptions lifecycle PRD** ([PRD.md](./PRD.md), from vhp-architecture +PR #154) against **Stripe Billing subscriptions** (statuses, schedules, trials, pause, proration +behavior, cancellation semantics) and **Zuora** (subscription versions/amendments/orders, terms, +effective-date trio, suspend/resume, owner transfer, ramps), to answer: *what lifecycle +capabilities are we missing?* + +Method (same buckets as the [pricing analysis](../../pricing/docs/STRIPE-GAP-ANALYSIS.md)): each +vendor concept is **HAVE** (already modelled), **DEFERRED/SPLIT** (consciously out of this PRD with +a recorded home), **OTHER GEAR** (correctly not a lifecycle concern), or **GAP** (worth a +decision). Only §5 items are proposed work. + +Sources checked (the pricing analysis initially failed by reading one gear only — not repeated +here): [PRD.md](./PRD.md) (the lifecycle PRD incl. its §15/§16 open items); the +absorbed predecessor `PRD-subscriptions-entitlements-202601120119` scope split (§2.2); +the BSS manifest §4.3 **status-enum constraint** (no new statuses without a manifest change); +the **pricing gear** ([PRD](../../pricing/docs/PRD.md) — phases D-19/D-41, `quantitySource` +D-18, `allowedChangeTargets`/`comparabilityRank`, §17.8 deferrals, cross-currency cancel+new) +and the **rating gear** ([PRD](../../rating/docs/PRD.md) — §6.11/§17.2 plan-change proration, +carry-vs-reset, ordering key). + +> **Local reconciliation finding (actioned with this analysis):** pricing PRD carried two stale +> phrases ("proration math is owned by Subscriptions", "Subscriptions executes proration") that +> contradict the normative three-way split (Subscriptions = change boundary/mode; rating = +> proration math; Billing = artifacts) stated by rating §6.11/§17.2 and [PRD.md](./PRD.md) §6.3 — +> the same defect the upstream reviewer flagged on PR #154 against the older upstream PRDs. Fixed +> in the pricing PRD alongside this document. + +> **Consolidation note (2026-07-15):** after this analysis was written, the predecessor +> `PRD-subscriptions-entitlements` was **merged into [PRD.md](./PRD.md)** (§2.2 section map +> there). Its own competitor matrix (Chargebee / Stripe / Zuora / CloudBlue) was reviewed and is +> consistent with this analysis: pause/resume popularity → G-4; renewal price lock → covered by +> pricing grandfathering (§4); trial conversion without re-entering payment details → G-2 (now +> actioned). + +## 2. Scope boundary — vendor features that are NOT ours + +Stripe/Zuora bundle payment, invoicing, and portal behavior into "subscriptions". In our gear +decomposition these are **OTHER GEAR** — recorded so the boundary is explicit: + +| Vendor feature | Owner in our architecture | +|---|---| +| `collection_method` (charge automatically vs send invoice + `days_until_due`) | Billing / Payments | +| Smart retries / dunning execution, retry schedules | Billing / Payments (Subscriptions consumes pre-check + retry-exhaustion signals — PRD §6.5) | +| Billing thresholds (invoice mid-cycle when usage amount crosses X) | Billing (post-aggregation spend caps; rating PRD §5.2 exclusion) | +| Invoice preview / upcoming invoice endpoint | Preview **owner** named in Design; calculation authority = rating gear (PRD §11) | +| Credit memos / credit notes, refunds execution | Billing / Payments | +| Tax on subscription invoices | Billing / Tax Engine | +| Customer portal (self-service UI shell) | Subscriptions/Frontend Design, not lifecycle PRD scope | +| Payment method management, `payment_behavior` at create | Payments (PSP) | +| Booking / revenue metrics (Zuora MRR, TCB/TCV deltas) | Analytics/DWH over lifecycle facts (PRD §6.7) | +| Proration **math** (`proration_behavior` arithmetic, day counts) | Rating gear (`prorationBasis` frozen in snapshot; rating §6.11/§17.2) | +| Coupons/discounts on subscription | Promotions (absent) + rating step 7 — see the [pricing analysis](../../pricing/docs/STRIPE-GAP-ANALYSIS.md) G-1 | + +## 3. Coverage map — vendor model vs what we have + +### 3.1 Status mapping + +The manifest enum is closed (`draft | active | suspended | cancelled | archived`); vendor +statuses map onto **status + attributes/phase**, per the PRD's trials decision: + +| Stripe status | Zuora status | Ours | +|---|---|---| +| `incomplete` / `incomplete_expired` | Draft | `draft` (+ payment-gated activation ordering — Design/Payments; see also G-6) | +| `trialing` | — (trial on rate plan) | `active` (or `draft`) + **trial phase / attributes** (PRD §6.1 — no `trial` status by design) | +| `active` | Active | `active` | +| `past_due` | — | `active` + **grace** (evaluated fields, 7-day default ladder — PRD §6.5) | +| `unpaid` | Suspended | `suspended` (grace ladder exit) | +| `paused` (`pause_collection`) | Suspended (with resume date) | `active` + **`collectionPaused`** posture (SUB-D-03, PRD §6.4); scheduled resume via `resumeAt` (SUB-D-01, PRD §6.1) | +| `canceled` | Cancelled | `cancelled` | +| — | Expired (term end) | `cancelled` via renewal ladder / non-renewal (Contract term) | +| — | — | `archived` (retention terminal — ours is richer) | + +### 3.2 Feature coverage (HAVE) + +| Vendor concept | Where we model it | +|---|---| +| Subscription as versioned aggregate (Zuora versions/amendments) | Monotonic `version` + immutable revisions + effective-dated `PlanLink`/`AddOn` (PRD §6.2) | +| Amendment types (NewProduct/RemoveProduct/UpdateProduct/Renewal/Cancellation/OwnerTransfer) | `TransitionRequest.type` inventory + composition ops (PRD §6.1) — see G-3 for the quantity residue | +| Plan changes with proration (Stripe `proration_behavior`, Zuora amendments) | Subscriptions owns `(changeEffectiveAt, changeMode)`; rating owns math on frozen `prorationBasis`; Billing materializes artifacts (PRD §6.3; rating §17.2) | +| Change timing modes (Stripe schedule at period end; Zuora effective dates) | `changeMode ∈ immediate / next-cycle / end-of-term` **on plan change** (PRD §6.3) — cancel/resume timing is G-1 | +| Allowed-change governance (no vendor equivalent) | `allowedChangeTargets` + `comparabilityRank` published by pricing, enforced by Subscriptions (pricing design/06) — **stronger than both vendors** | +| Tier counter behavior across a change (no vendor equivalent) | Tier-`Q` / commitment carry-vs-reset frozen in snapshot (rating §17.2) | +| Trials (Stripe `trial_period_days`, trial price) | Catalog-first trial offers + leading **trial phase** (pricing D-19/D-41) + subscription attributes (PRD §6.1); conversion depth in predecessor PRD | +| Backdating (Stripe `backdate_start_date`, Zuora backdated orders) | §6.3 backdated rules + AC 6 (reject when a posted invoice would be contradicted → adjustment path) | +| Renewal & auto-renew (Zuora terms; Stripe implicit evergreen) | Contract §4.6 `Renewal.autoRenew` + term windows; renewal job + evaluated fields; idempotent attempts (PRD §6.5) | +| Failed payment ladder (Stripe `past_due→unpaid/canceled` per settings) | Grace policy: 7-day default, paused next-term recurring, Contract SoR, hybrid exit (PRD §6.5) — Contract-configurable, i.e. stronger | +| Suspend / resume (Zuora suspend/resume) | `suspended` state + Policy-gated `resume`, entitlement freeze/re-issue (PRD §6.4) | +| Owner transfer (Zuora invoice-owner vs subscription-owner) | `payerTenantId` vs `resourceTenantId` vs `sellerTenantId` axes + `transfer` with Approval + `OwnershipTransfer*` events + delegation proofs (PRD §6.6) — stronger | +| Duplicate-subscription guard (no first-class vendor equivalent) | `overlapScopeKey` + `maxConcurrentActive` fail-closed on activate (PRD §6.3) | +| Recurring charge generation (Stripe invoice cycle; Zuora bill runs) | `BillableItem(kind=recurring)` idempotent per `(subscriptionId, period)`; `billingAnchor` + pricing `billingAnchorPolicy` (PRD §6.8) | +| Cancellation reasons / audit (Stripe `cancellation_details`) | TransitionRequest + audit lineage + events (PRD §6.1/§6.7); free-form reason fields are Design | +| Webhooks / event feed | CloudEvents 1.0 producer inventory + `(tenantId, subscriptionId)` ordering (PRD §6.7) | +| Quantity-based pricing (Stripe `items.quantity`) | `quantitySource = subscription_seat_count` — Subscriptions supplies the per-period count (pricing D-18); the **update verb** is G-3 | + +## 4. Already deferred or consciously split, with vendor equivalents + +Not gaps — each has a recorded home; mapped so nobody rediscovers them: + +| Deferred / split item | Vendor analogue | Where recorded | +|---|---|---| +| Self-service `termLength` / `autoRenew` on the Plan | Zuora termed subscriptions (initial + renewal terms) | pricing §17.8 (Follow-on, decided 2026-07-04); negotiated terms = Contracts §4.6 — split is deliberate | +| Cross-currency / region / frequency mid-cycle change | Stripe allows currency-crossing schedule phases | pricing PRD: **cancel + new**, in-place rejected at launch | +| Trial runtime, conversion, notices | Stripe trial emails / portal | **merged** into [PRD.md](./PRD.md) §6.10 (runtime/conversion) + §6.5 (notice triggers); campaign content & delivery = Notifications (PRD §5.2). G-2 operation actioned | +| Entitlement enforcement at point of use (quotas, flags, exhaustion) | — (vendors don't own entitlements) | **merged** PRD §6.9 — state + check contract here (p95 < 100ms), enforcement execution = OSS | +| Committed usage / prepaid commercial variants | Zuora prepaid drawdown | Contracts pools + rating true-up (T-D-14) + pricing prepaid grant (D-43); subscription-side hooks in the merged PRD (§2.2) | +| Renewal price lock at renewal | Zuora renewal price lock (enterprise churn guard) | pricing `priceEligibility` + `cohort` grandfathering generations (pricing ADR-0002); the predecessor's P1 scope row is superseded there | +| Rule-based change targets | — | pricing D-23: explicit `allowedChangeTargets` lists at launch; rules = Future | +| Payment-gated plan change (Stripe `pending_updates` — apply only if payment succeeds) | Stripe `pending_updates` | Payments/Billing integration detail — Design scope per PRD §2 (manifest silence list); revisit if self-service upgrades require payment-first commit | +| Event attribute matrices, REST/header bindings | Stripe API schemas | `DESIGN-subscriptions-*` (PRD content boundary) | + +## 5. Gaps worth acting on + +Ranked. Each is a real delta against Stripe/Zuora that is **not** already deferred with a +recorded home and is a lifecycle (this-gear) concern. + +### G-1. Scheduled lifecycle intents (cancel at period end / at date; scheduled resume) + +**State today**: `changeMode` (immediate / next-cycle / end-of-term) exists **only for +`changePlan`** (PRD §6.3). `cancel` is an immediate transition; `resume` is an operator action. +There is no way to record the single most common self-service intent — **"cancel at period +end"** — nor "suspend until date / resume at date". + +**Vendor model**: Stripe `cancel_at_period_end` + `cancel_at` (and un-scheduling by clearing +them), `cancellation_details`; Zuora cancellation policies (end of term / end of last invoice +period / specific date) and **suspend with resume date**. The predecessor PRD's own glossary +already names the concept — **Cancellation Policy** "(immediate, end-of-term, prorated refund +eligibility)" — but the lifecycle PRD carried no operation for it. + +**Why it matters**: without a first-class scheduled intent, portals implement "cancel at period +end" as an external cron + immediate cancel — invisible to renewal evaluation (§6.5 would still +attempt renewal), to Billing (next-term recurring must not be emitted for a lapsing term), and +to audit. The intent must live **on the aggregate** so the renewal job, grace ladder, and event +consumers see it. + +**Options**: (a) extend `TransitionRequest` with the same effective-timing envelope plan changes +already have — `cancelMode ∈ {immediate, end_of_term, at(date)}` (+ `resumeAt` on suspend), a +stored **pending intent** on the subscription, cancellable until effective, evaluated by the +renewal job; (b) leave scheduling to portal-side automation (status quo). + +**Recommendation**: (a) — it reuses the §6.3 mode vocabulary, closes the renewal-interaction +hole, and is AC-testable ("a subscription with a pending end-of-term cancel MUST NOT renew and +MUST NOT emit next-term recurring"). Smallest high-value addition in this analysis. + +**Actioned — SUB-D-01 (2026-07-15):** merged PRD §6.1 `fr-scheduled-intents` (+ AC 21–22): +`cancelMode { immediate, end_of_term, at(date) }`, `resumeAt`, pending intents on the aggregate +with renewal suppression and un-scheduling; manifest envelope alignment tracked in PRD §15. + +### G-2. First-class early trial conversion + +**State today**: trials are a leading **plan phase** with `convertsToPhaseId` + +`phaseDurationDays` (pricing D-19); conversion happens when the phase clock runs out. The +lifecycle PRD treats early conversion as a generic composition/attribute change and defers +conversion **workflows** to the predecessor. There is no `convertTrial` / phase-advance +**operation** — a long-standing open item (also visible in the studio prototype's +"Convert to paid now" button, which today maps to nothing normative). + +**Vendor model**: Stripe — set `trial_end = now` on the subscription (first-class, prorates and +starts the paid cycle immediately); Zuora — amendment moving the charge segment. + +**Why it matters**: "skip the trial, start paying now" is a conversion-funnel operation product +teams run constantly; modeling it as an untyped attribute edit loses the Policy gate, the +entitlement re-issue for the new phase (trial caps → evergreen caps, pricing D-41), the event +(`SubscriptionConverted`-class), and idempotency. + +**Options**: (a) add a `convertTrial` (phase-advance) `TransitionRequest` type: sets the phase +boundary at `now` (a `changeEffectiveAt` for the phase axis), re-issues entitlements per the +phase grant map, emits a first-class event; (b) express it as `changePlan` onto the same plan +with a phase override (abuses plan-change semantics); (c) keep it predecessor/Design territory. + +**Recommendation**: (a) — it is the phase-axis twin of `changePlan` and everything it needs +(phase map, per-phase grants, proration boundary consumption) already exists in the pricing and +rating gears. + +**Actioned — merged PRD §6.10 (2026-07-15):** `convertTrial` is now first-class +(`fr-trial-early-conversion` + AC 17): explicit `TransitionRequest`, phase boundary at `now` +consumed by the rating gear, entitlement re-issue per the target phase, first-class event; the +manifest `TransitionRequest.type` extension is tracked in PRD §15. + +### G-3. Seat / quantity change as a lifecycle transition + +**State today**: per-seat pricing resolves quantity from `quantitySource = +subscription_seat_count` — "Subscriptions supplies the per-period seat count at rating time" +(pricing D-18). But the lifecycle PRD's `TransitionRequest.type` inventory has **no quantity +operation**: seat-count changes are not a modeled transition, have no `changeMode`/proration +trigger, no event, no idempotency envelope. + +**Vendor model**: Stripe `items.quantity` update with `proration_behavior`; Zuora +`UpdateProduct` amendment (quantity) with effective date. + +**Why it matters**: mid-period seat growth is the most frequent commercial mutation on B2B +subscriptions. Without a transition it cannot be Policy-gated (seats often carry entitlement +quotas), cannot prorate deterministically (rating needs a boundary), and the seat count Rating +reads has no auditable provenance. + +**Options**: (a) add `updateQuantity` to the `TransitionRequest` inventory with the §6.3 +envelope (`changeEffectiveAt`, `changeMode`), emitting a composition-changing event consumed +like `SubscriptionPlanChanged`; (b) treat seat count as a plain attribute with audit only (no +proration boundary — mid-period seat changes bill from next cycle only). + +**Recommendation**: (a), with (b)'s "next-cycle only" as the launch-default `changeMode` if +Product wants to avoid mid-period seat proration initially — the envelope still makes that an +explicit, auditable choice. + +**Actioned — SUB-D-02 (2026-07-15):** merged PRD §6.3 `fr-update-quantity` (+ AC 23, §6.1 type +list): full envelope; increases MAY be immediate (prorated by rating), decreases default +next-cycle; seat counts consumed by rating originate from committed transitions only. + +### G-4. Billing-only pause (collection paused, service running) + +**State today**: §6.4 makes **suspension** billing posture explicit (pause recurring vs continue +to charge) — but suspension always changes the **service** posture (entitlement freeze, OSS +pause). The inverse — **keep service running, pause collection** (hardship pauses, disputes, +goodwill) — has no representation. + +**Vendor model**: Stripe `pause_collection` (subscription stays serviced; invoices +`keep_as_draft` / `mark_uncollectible` / `void`); Zuora handles it contractually. The +predecessor PRD's glossary likewise defines **Subscription Pause** ("temporary suspension +preserving subscription state; no charges during pause period") — a pause concept the +lifecycle PRD never modeled (it has only service-affecting suspension). + +**Why it matters**: partners use collection pauses to keep customers alive during disputes +without service interruption; forcing a `suspended` state for it revokes entitlements — the +opposite intent. + +**Options**: (a) a subscription **attribute posture** on `active` (mirror of the §6.4 pattern): +`collectionPaused` with an auditable window, consumed by Billing's recurring generation (skip / +draft per policy) — no new status (manifest enum untouched); (b) declare it a Billing-side +account-level hold, out of the lifecycle aggregate. + +**Recommendation**: decide the owner first — (a) if the pause must ride subscription events and +renewal logic (likely, since §6.5 already pauses blocked next-term recurring — same mechanism), +(b) if Finance wants it as an AR-side hold. Either way, record it; today it is silently +unrepresentable. + +**Decided — SUB-D-03 (2026-07-15):** option (a) — `collectionPaused` posture on `active` +(merged PRD §6.4 `fr-collection-pause` + AC 24), collection-scoped, auditable window; +pause-day limits and resume proration remain open (PRD §15). Veto-flagged — Finance may still +prefer the AR-hold shape. + +### G-5. Committed multi-step schedules (ramps) and atomic multi-action orders + +**State today**: future intent is expressible only as **one** pending boundary per concern +(scheduled `PlanLink` change, scheduled window on the catalog side). A committed multi-step +plan — "100 seats in Q1 → 200 in Q2 → 300 in Q3", or "phase into planB at date X then planC at +Y" — has no aggregate-level representation, and there is no **atomic multi-action** change (Zuora +Orders bundle several actions/subscriptions into one audited order). + +**Vendor model**: Stripe **subscription schedules** (ordered phases with prices/quantities + +`end_behavior`); Zuora **Ramps** (committed ramp intervals with deal metrics) and **Orders** +(atomic multi-action). + +**Why it matters**: negotiated growth deals (typical for ACP/enterprise channel) need the whole +ramp committed at signature for revenue planning (and ASC inputs), not re-keyed quarterly by an +operator. + +**Options**: (a) defer to **Contracts** — a ramp is a contract term; Subscriptions executes each +step as a scheduled `changePlan`/`updateQuantity` generated from the contract (needs only the +G-1/G-3 envelopes + a Contracts follow-up); (b) native `SubscriptionSchedule` aggregate +(Stripe-style) inside this gear; (c) status quo (operator re-keys each step). + +**Recommendation**: (a) — matches the SoR split (negotiated commitments already live in +Contracts, cf. rating T-D-14 pools) and keeps this gear's aggregate simple; record it as a +cross-PRD decision, not silence. (b) only if self-service ramps become a product goal. + +**Decided — SUB-D-04 (2026-07-15):** option (a) — merged PRD §6.3 `fr-ramp-execution` +(Contracts authors; Subscriptions executes generated scheduled intents, riding SUB-D-01/02); +atomic multi-action submission = Contracts/Design follow-up (PRD §15). + +### G-6. Activation date trio (booking / service / acceptance) + +**State today**: `activate` is a single instant (Policy + OSS + entitlements). ASC 606 hooks are +"tags/snapshot refs" (PRD §5.1). There is no distinction between **booking** (contract +effective), **service activation**, and **customer acceptance** instants. + +**Vendor model**: Zuora's triple — `ContractEffectiveDate` (booking/TCB), `ServiceActivationDate` +(service starts), `CustomerAcceptanceDate` (revenue-recognition trigger where acceptance +clauses apply); pending-activation / pending-acceptance interim states. Stripe has no analog +(SMB bias). + +**Why it matters**: enterprise/channel deals with acceptance clauses need the three instants for +correct revenue timing downstream — exactly the "ASC inputs" this PRD promises to carry. A +single `activatedAt` collapses them. + +**Options**: (a) record the trio as **subscription attributes/evaluated fields** stamped by the +existing flow (created/contract-signed → activate → optional acceptance confirmation), emitted +on events for Finance/Billing — no new statuses (manifest enum untouched); (b) extend the +manifest with interim statuses (pending-acceptance) — heavy, enum change; (c) declare booking +and acceptance dates a Contracts/Finance concern referenced by the subscription. + +**Recommendation**: decision-first, default (a)+(c): attributes on the aggregate, SoR for +booking/acceptance semantics in Contracts/Finance, no enum change. Low effort once decided; +without it the ASC-hook promise is under-specified. + +**Decided — SUB-D-05 (2026-07-15):** (a)+(c) — merged PRD §6.1 `fr-activation-instants` +(+ AC 25): `contractEffectiveAt` (Contract-referenced) / `serviceActivatedAt` (stamped at +activate) / `customerAcceptedAt` (acceptance confirmation where clauses apply); interim +statuses rejected; confirmation-flow shape → Design (PRD §15). + +## 6. Recommendation summary + +| # | Gap | Effort | Outcome | +|---|---|---|---| +| G-1 | Scheduled lifecycle intents (cancel at term end / at date; resume at date) | **Actioned** | SUB-D-01 → merged PRD §6.1 `fr-scheduled-intents` + AC 21–22 (2026-07-15) | +| G-2 | First-class early trial conversion (`convertTrial` phase-advance) | **Actioned** | Merged PRD §6.10 `fr-trial-early-conversion` + AC 17 (2026-07-15); manifest type-enum alignment tracked in PRD §15 | +| G-3 | Seat/quantity change transition (`updateQuantity`) | **Actioned** | SUB-D-02 → merged PRD §6.3 `fr-update-quantity` + AC 23; increases immediate / decreases next-cycle by default | +| G-4 | Billing-only pause (collection paused, service running) | **Decided** | SUB-D-03 → `collectionPaused` posture on `active` (PRD §6.4 + AC 24); mechanics open in PRD §15 | +| G-5 | Ramps / atomic multi-action orders | **Decided** (cross-PRD) | SUB-D-04 → Contracts authors, Subscriptions executes scheduled intents (PRD §6.3); Contracts follow-up in PRD §15 | +| G-6 | Activation date trio (booking/service/acceptance) | **Decided** | SUB-D-05 → attribute trio + acceptance confirmation (PRD §6.1 + AC 25); no enum change | + +All six gaps are processed — G-1/G-2/G-3 actioned normatively, G-4/G-5/G-6 decided with +veto-flagged defaults ([DECISIONS.md](./DECISIONS.md) SUB-D-01…05). Everything in §2 +(payment/collection/portal/metrics), §3 (HAVE), and §4 (recorded deferrals/splits) needs **no +action**. The local pricing-PRD proration-ownership wording was fixed alongside this analysis +(§1 note). diff --git a/gears/bss/subscriptions/docs/design/01-foundation-lifecycle.md b/gears/bss/subscriptions/docs/design/01-foundation-lifecycle.md new file mode 100644 index 000000000..f6191ef55 --- /dev/null +++ b/gears/bss/subscriptions/docs/design/01-foundation-lifecycle.md @@ -0,0 +1,333 @@ + + + +# DESIGN — Lifecycle Foundation (Slice 1) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-foundation` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Closed Status Machine and Terminality (normative)](#41-closed-status-machine-and-terminality-normative) + - [4.2 TransitionRequest Envelope, Idempotency, Ordering (normative)](#42-transitionrequest-envelope-idempotency-ordering-normative) + - [4.3 Scheduled Intents on the Aggregate (normative)](#43-scheduled-intents-on-the-aggregate-normative) + - [4.4 Activation Instants Trio (normative)](#44-activation-instants-trio-normative) + - [4.5 Fail-Closed Policy and OSS Gate (normative)](#45-fail-closed-policy-and-oss-gate-normative) + - [4.6 Manifest Alignment of New Transition Types (normative)](#46-manifest-alignment-of-new-transition-types-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **shared substrate** every capability slice runs *through*: the `Subscription` +aggregate, the **closed manifest status machine**, and the single **transition commit path** that +takes a `TransitionRequest` from validation to a durable, idempotent, versioned, audited, evented +state change. No capability slice commits state itself — renewal (slice 04), entitlements (05), and +trials (06) express *what* the change is and *when*; the Foundation owns *how it commits* so the +correctness-critical guarantees (idempotency, ordering, the fail-closed Policy/OSS gate, terminality) +live in one auditable place ([`../PRD.md`](../PRD.md) §6.1). + +Two invariants shape the slice. First, **the status enum is closed and terminal**: manifest §4.3 +lists exactly `draft | active | suspended | cancelled | archived`, and this gear never adds a state +— trials, the billing-only pause, and scheduled intents are **attributes, postures, and +pending-intents** on that enum, never new statuses ([`../PRD.md`](../PRD.md) §6.1; +[`../DECISIONS.md`](../DECISIONS.md) SUB-D-03/05). Second, **every resource-affecting transition is +fail-closed**: it passes the Policy Engine pre-check before commit, and on deny *or* Policy +unavailability the state does not change ([`../PRD.md`](../PRD.md) §6.1 AC 1; SEAMS **SUB-E1**). The +slice owns the seams **SUB-E1** (the gate), **SUB-C4** (the activation date-trio), and **SUB-N1** +(the manifest alignment of the new transition types). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-status-enum` / `cpt-cf-bss-subscriptions-fr-transitions-guards` | A `StatusMachine` with the closed enum, the seven normative edges (incl. the `resume` reverse edge and the `draft→cancelled` void, SUB-D-11), terminality on `cancelled`/`archived`, and a guard per edge; an edge not in the table is rejected, never inferred (§4.1). | +| `cpt-cf-bss-subscriptions-fr-transition-request` | A uniform `TransitionRequest` envelope (`type`, `idempotencyKey`, `status ∈ {pending, approved, applied, failed}`) is the *only* mutation shape; the `TransitionEngine` is the single commit path (§4.2). | +| `cpt-cf-bss-subscriptions-fr-scheduled-intents` | Non-immediate `cancelMode`/`resumeAt` intents are `ScheduledIntent` rows **on the aggregate**, visible to the renewal job, un-schedulable until effective, evented both ways (§4.3; SUB-D-01). | +| `cpt-cf-bss-subscriptions-fr-activation-instants` | Three instants — `contractEffectiveAt` (from Contract), `serviceActivatedAt` (stamped at commit), `customerAcceptedAt` (acceptance confirmation) — as aggregate attributes; no interim statuses (§4.4; SUB-D-05, SUB-C4). | +| `cpt-cf-bss-subscriptions-fr-monotonic-version` | Each committed commercial-meaning change increments `version` and appends an immutable `SubscriptionRevision`; optimistic concurrency rejects a stale submit (§3.1, §3.7). | +| `cpt-cf-bss-subscriptions-fr-event-ordering` | Commit and emission are sequenced per the pinned `(orderingTenantId, subscriptionId)` (SUB-D-06) — the key shared with rating partition ordering (§4.2; SUB-R1). | +| `cpt-cf-bss-subscriptions-fr-trials-not-a-status` | The closed enum is enforced structurally: a `trial`/`pending` value is unrepresentable; trials ride phase attributes (slice 06) on a manifest status (§4.1). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-lifecycle-latency` | `TransitionEngine` commit path | Synchronous commit class (p95 < 1s) = validate + idempotency + guard + Policy pre-check + single versioned write; **OSS-blocking edges are excluded** — they run async (`pending → approved → applied`, §3.6 async note), the sub-second bound applies to the intent commit | Load test; baseline (workshop-pending [`../PRD.md`](../PRD.md) §7.1, §14) | +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | `StatusMachine`, `IdempotencyRegistry` | State-transition p95 < 500ms is the same commit path **excluding the external Policy round-trip** (the 1s class includes it); exactly-one durable effect per `(subscriptionId, idempotencyKey)` under retry | Idempotency + concurrency fixtures | +| `cpt-cf-bss-subscriptions-nfr-horizontal-partitioning` | Aggregate store + `OrderingSequencer` | Partition by the pinned `orderingTenantId` (SUB-D-06 — stable across transfers, no row migration); per-aggregate ordering with no cross-partition lock on the commit path | Design + load test | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| [`../ADR/0001`](../ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md) `cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine` | Trials, billing-only pause, and scheduled intents are attributes/postures/pending-intents on the closed manifest enum — no new status (§4.1). | +| [`../ADR/0003`](../ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md) `cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate` | Scheduled cancel/resume/ramp intents live on the aggregate so the renewal job, Billing, and audit can see them — not as portal-side automation (§4.3). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-fnd` + +```text +Control plane (REST) create / activate / suspend / resume / cancel / changePlan / ... + │ (idempotency key + ETag/version) → TransitionRequest + ▼ +TransitionEngine (this slice) Validate → IdempotencyRegistry → (Approval hold) → + │ StatusMachine guard → version check → PolicyGate (fail-closed) → + │ OssCoordinator (confirm; async edges) → + │ commit + VersionStamper + AuditWriter → OrderingSequencer → outbox + ▼ +Aggregate store + read models Subscription + SubscriptionRevision (append-only) · + TransitionRequest · ScheduledIntent · Approval · audit · outbox +``` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | REST control-plane behind the inbound gateway; RFC 9457 problems; OAuth 2.0; idempotency key + ETag optimistic concurrency (mappings in slice 09 / Design) | Rust, REST/OpenAPI, inbound API gateway | +| Application | The `TransitionEngine` commit path + the capability handlers that invoke it | Rust modules in the `subscriptions` gear | +| Domain | The `Subscription` aggregate, `StatusMachine`, `TransitionRequest`/`ScheduledIntent`/`Approval`, versioning, activation instants | Rust; GTS + Rust domain structs | +| Infrastructure | Aggregate + append-only revision history, projected read models, audit store, event outbox | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Single commit path + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-single-commit-path-fnd` + +Every state change reaches the store one way: `TransitionRequest` → `TransitionEngine` (validate → +guard → gate → OSS confirm → commit + version + audit + emit). There is no side door that mutates a +committed subscription ([`../PRD.md`](../PRD.md) §6.1). + +#### Closed enum, enforced structurally + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-closed-enum-fnd` + +`Subscription.status` is a closed domain type; a value outside `{draft, active, suspended, +cancelled, archived}` is unrepresentable. Trial/pause/intent state is modelled as attributes and +pending intents, never as a status ([`../PRD.md`](../PRD.md) §6.1; [`../DECISIONS.md`](../DECISIONS.md) +SUB-D-03/05). + +#### Idempotent and ordered + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-idempotent-ordered-fnd` + +A duplicate `(subscriptionId, idempotencyKey)` yields exactly one durable effect and replays the +original outcome; commit and emission preserve order within the pinned `(orderingTenantId, subscriptionId)` +([`../PRD.md`](../PRD.md) §6.7, §6.8). + +### 2.2 Constraints + +#### Fail-closed gate is mandatory + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-fail-closed-fnd` + +A resource-affecting transition that cannot obtain a Policy **allow** — deny or Policy unavailable — +does not commit; the state is unchanged and the attempt is audited ([`../PRD.md`](../PRD.md) §6.1 AC +1; SEAMS **SUB-E1**). + +#### Terminality is irreversible + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-terminality-fnd` + +`cancelled` and `archived` are terminal; the only forward move is `cancelled → archived`. There is +no commercial rebirth — reactivating a cancelled commercial relationship is a **new subscription** +([`../PRD.md`](../PRD.md) §6.1). + +#### No money, no catalog authoring + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-no-money-fnd` + +The Foundation commits lifecycle state only; it computes no charge/proration/FX and authors no +catalog entity. Monetary effects are downstream (rating math, Billing posting), driven by the events +this slice emits ([`../PRD.md`](../PRD.md) §6.3, §5.2). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-fnd` + +- **`Subscription`** (aggregate root) — `subscriptionId`, `status` (closed enum), `version` (monotonic), tenant axes (`resourceTenantId`, `payerTenantId`, `sellerTenantId`), `brandId` (per-sale, slice 02), the activation instants (`contractEffectiveAt`, `serviceActivatedAt`, `customerAcceptedAt`; §4.4), posture flags (`collectionPaused` window — attribute here, semantics slice 04), and references to its composition (slice 02), pending intents, and entitlements (slice 05). +- **`TransitionRequest`** — `id`, `subscriptionId`, `type ∈ {activate, suspend, resume, cancel, archive, changePlan, addAddOn, removeAddOn, updateQuantity, convertTrial, transfer, renew, unschedule, pauseCollection, resumeCollection, confirmAcceptance, extendTrial}` (SUB-D-08 completes the set so every FR-mandated mutation has a type; `archive` is the `cancelled→archived` edge operation), `idempotencyKey`, `status ∈ {pending, approved, applied, failed}`, the change envelope (`changeEffectiveAt`, `changeMode` / `cancelMode`, `resumeAt`), `correlationId`, actor + delegation-proof reference. +- **`ScheduledIntent`** — a pending non-immediate intent on the aggregate: `kind` (cancel/resume/ramp step), `effectiveAt`, source `TransitionRequest`, `unscheduledAt?`; suppresses renewal / next-term recurring while pending (§4.3). +- **`Approval`** — the maker-checker record required for high-risk types (`transfer`; trial extension slice 06): submitter, approver(s), decision, evidence. +- **`SubscriptionRevision`** — an append-only immutable snapshot per committed version (audit + replay lineage). +- **`AuditRecord`** — append-only, tamper-evident, correlation-keyed record of every transition attempt (incl. denied/failed). + +Catalog (`Plan`/`Price`/`PriceWindow`) and registry (`skuId`/`PlanTier`/`CatalogVersion`) entities +are **resolved frozen inputs**, never owned here. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-transition-engine-fnd` + +- **`TransitionEngine`** — the single commit path; orchestrates validate → guard → idempotency → gate → OSS confirm → commit → version → audit → emit. +- **`StatusMachine`** — the closed enum + edge/guard table; rejects any edge not in the table (§4.1). +- **`IdempotencyRegistry`** — dedup on `(subscriptionId, idempotencyKey)`; returns the original outcome on replay (§4.2). +- **`OrderingSequencer`** — assigns and enforces per-`(orderingTenantId, subscriptionId)` sequence (the pinned tenant, SUB-D-06) for commit + outbox emission (§4.2). +- **`IntentScheduler`** — persists/cancels `ScheduledIntent`s and surfaces them to the renewal job; fires the real transition at `effectiveAt` with the full guard set (§4.3). +- **`PolicyGate`** — the fail-closed Policy pre-check; deny/unavailable ⇒ abort (§4.5). +- **`OssCoordinator`** — issues provision/deprovision/pause work orders and blocks the commit on confirmation where the edge requires it (§4.5). +- **`VersionStamper` + `AuditWriter`** — increment `version`, append the revision + audit record atomically with the commit. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-transition-fnd` + +The **internal transition contract** (in-process): input = a validated `TransitionRequest`; output = +the committed `Subscription` revision + the emitted event set, or a typed rejection +(`policy_denied`, `guard_violation`, `stale_version`, `duplicate_idempotency` → original outcome, +`oss_unconfirmed`). The **REST control-plane** surface (`cpt-cf-bss-subscriptions-interface-control-plane`, +[`../PRD.md`](../PRD.md) §9.1) — paths, methods, idempotency/ETag header bindings, RFC 9457 problem +mappings — is owned by [`09-consumer-contracts.md`](./09-consumer-contracts.md); this slice fixes the +**semantics** (which rejections exist, which are fail-closed), not the wire mapping ([`../PRD.md`](../PRD.md) +§6 content boundary). + +### 3.4 Internal Dependencies + +Every capability slice depends on this Foundation for its commit path. Within the slice: +`toolkit-db` for transactional persistence (aggregate + revision + audit + outbox in one commit) and +the **coordination lease library** for the singleton `IntentScheduler` firing and background work. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Policy Engine | Pre-commit allow/deny + `reasonCodes` for resource-affecting transitions | [`../PRD.md`](../PRD.md) §9.2 policy-gate; SEAMS **SUB-E1** | +| OSS Provisioning | Provision/deprovision/pause work orders confirmed by events; BSS never mutates OSS topology | [`../PRD.md`](../PRD.md) §9.2 oss-provisioning; SEAMS **SUB-E2** | +| AMS | Tenant identity + the three axes + delegation-proof backbone (referenced, never invented) | [`../PRD.md`](../PRD.md) §6.6 | +| Contracts | `contractEffectiveAt` booking instant + acceptance clauses (§4.4) | SEAMS **SUB-C4** | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-flow-transition-commit-fnd` + +**Policy-gated transition** (refines `cpt-cf-bss-subscriptions-seq-policy-gated-transition`): + +1. Validate the request; resolve the caller's scope + delegation proof for cross-tenant actions (reject + audit if absent). +2. `IdempotencyRegistry` **first**: a seen `(subscriptionId, idempotencyKey)` short-circuits to the **original outcome** — before any guard or version check, so a retry of an already-applied transition can never surface as `guard_violation`/`stale_version` (2026-07-15 review fix). +3. **Approval hold** (approval-required types: `transfer`, `extendTrial`): the request parks `pending → approved` (maker-checker) before evaluation; on approval the flow continues with a **re-read** of current state; the idempotency key covers the whole envelope across the hold. +4. `StatusMachine` guard: the `(from, to)` edge must exist and its preconditions hold (else `guard_violation`). +5. Optimistic concurrency: the submitted `version` must match (else `stale_version`). System-originated firings (scheduled intents, renewal job) re-read the current `version` instead of carrying a client ETag. +6. `PolicyGate` pre-check for resource-affecting edges: deny **or** unavailable ⇒ abort with no state change (fail-closed). +7. `OssCoordinator`: where the edge provisions/deprovisions, issue the work order and coordinate confirmation (§4.5 — confirmation is event-driven; the OSS-blocking edge classes run asynchronously, see the async note below). +8. Commit atomically: new `status`/attributes + `version` increment + `SubscriptionRevision` + `AuditRecord` + entitlement issue/revoke (slice 05) + outbox events — sequenced by `OrderingSequencer`. + +**Async note (OSS-blocking edges).** Edges that require OSS provisioning confirmation (`activate`, `suspend`/`resume` with resource legs, `cancel` deprovision) are **not** in the synchronous sub-second class: the `TransitionRequest` commits its *intent* synchronously (`approved`, work order issued) and the status edge commits when the confirmation event arrives; the client tracks `pending → approved → applied`. On confirmation timeout (Design timers) the request fails `oss_unconfirmed` and the work order is **idempotently cancelled**; a confirmation arriving after the timeout hits the cancelled order and is reconciled (compensating deprovision work order + audit) — no orphaned resource without a committed transition. + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-scheduled-intent-fnd` + +**Scheduled-intent lifecycle**: a non-immediate `cancel`/`suspend` (or ramp step) persists a +`ScheduledIntent` and emits `SubscriptionIntentScheduled`; the renewal job (slice 04) observes it (a +pending end-of-term cancel suppresses renewal + next-term recurring); an `unschedule` (SUB-D-08) +before `effectiveAt` voids it and emits `SubscriptionIntentUnscheduled`; at `effectiveAt` the +`IntentScheduler` runs the real transition through the full commit path above (§4.3). The firing is +**idempotent by derivation**: its idempotency key is derived as `intent:{scheduledIntentId}`, so a +crashed-and-retried scheduler can never double-fire; the firing re-reads the current `version` +(step 5 note) and runs the full guard set — a Policy deny at firing leaves state unchanged. + +### 3.7 Database Schemas and Tables + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-storage-aggregate-fnd` + +Owned here (tenant-partitioned by the pinned `orderingTenantId`, SUB-D-06 — stable across transfers, UTC): `subscription` (aggregate current +state), `subscription_revision` (append-only history; `REVOKE UPDATE, DELETE` + triggers), +`transition_request` (with the `(subscriptionId, idempotencyKey)` unique index), `scheduled_intent`, +`approval`, the append-only `audit_log`, and the `event_outbox`. Capability slices add their own +tables (composition in 02, grace evaluation in 04, entitlements in 05, trial phase state in 06, +transfer approvals in 07). Concrete DDL is Design-owned; money never lives here (no monetary column). + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-fnd` + +A stateful control-plane service over a shared `toolkit-db`, partitioned by the pinned +`orderingTenantId` (SUB-D-06 — stable across transfers, no partition migration). The +`IntentScheduler` and other background jobs run as coordinated singletons **per tenant-partition +shard** (one lease per `orderingTenantId`, shard-parallel across partitions) via the coordination +lease library — no single global instance funnels the 100K+/tenant scale (slices 04/08 §3.8). Read models (entitlement check, roll-ups) are projected off the commit path. Deployment is +platform-standard for a BSS gear ([`../DESIGN.md`](../DESIGN.md) §3.8). + +## 4. Additional Context + +### 4.1 Closed Status Machine and Terminality (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-status-machine-fnd` + +- The enum is exactly `draft | active | suspended | cancelled | archived` (manifest §4.3); the edges are `draft→active` (activate), `draft→cancelled` (void — SUB-D-11; not resource-affecting, `cancelMode = immediate` only, no OSS leg), `active→suspended` (suspend), `suspended→active` (resume; a grace-driven suspension additionally requires the blocking payment failure resolved — PRD §6.5), `active→cancelled` / `suspended→cancelled` (cancel), `cancelled→archived` (archive) ([`../PRD.md`](../PRD.md) §6.1). An edge not in the table is rejected — no silent transition. +- `cancelled` and `archived` are terminal; `cancelled→archived` is the only forward move; there is no reverse from either. Reactivation of a cancelled relationship is a new subscription. +- **Archive trigger (2026-07-15 review fix):** `archive` is the operation on the `cancelled→archived` edge — a `TransitionRequest` of type `archive` (not resource-affecting, no OSS leg), submitted either by an operator or by a **retention job** that archives `cancelled` subscriptions past a configurable dwell TTL (Product knob, §15 — same family as the SUB-D-11 draft-void TTL). Archival is a data-lifecycle move (read-model demotion / cold-storage eligibility), never a commercial state change, and rides the audit trail like any transition. `archive` joins the §4.2 `type` list under manifest alignment (§4.6, SUB-N1) — it was missed by the SUB-D-08 completion set even though the edge was already named here. +- Trials, `collectionPaused`, and scheduled intents are attributes/postures/pending-intents on this enum; adding a `trial`/`pending`/`paused` status requires a **manifest change first** ([`../DECISIONS.md`](../DECISIONS.md) SUB-D-03/05). + +### 4.2 TransitionRequest Envelope, Idempotency, Ordering (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-envelope-fnd` + +- All mutations are `TransitionRequest`s; `type ∈ {activate, suspend, resume, cancel, archive, changePlan, addAddOn, removeAddOn, updateQuantity, convertTrial, transfer, renew, unschedule, pauseCollection, resumeCollection, confirmAcceptance, extendTrial}` (all non-manifest types + the scheduled envelope pending manifest alignment, §4.6; SUB-D-08). High-risk types (`transfer`, `extendTrial`) require an `Approval` ([`../PRD.md`](../PRD.md) §6.1). +- **Idempotency**: exactly one durable effect per `(subscriptionId, idempotencyKey)`; a replay returns the original outcome, never a second effect — the registry lookup runs **before** guard and version evaluation (§3.6 step 2) ([`../PRD.md`](../PRD.md) §6.1, §6.8 AC 5). +- **Ordering**: commit + outbox emission are sequenced within `(orderingTenantId, subscriptionId)` — `aggregateId = subscriptionId`, and the ordering tenant is the **pinned-at-creation** `orderingTenantId` (= `resourceTenantId` at creation, immutable across transfers — SUB-D-06) so subscription command ordering and downstream rating partition ordering share one stable key ([`../PRD.md`](../PRD.md) §6.7; SEAMS **SUB-R1**). +- **Concurrency**: optimistic on `version`; a stale submit is rejected (ETag mapping in slice 09); system-originated firings re-read `version` instead of carrying one. + +### 4.3 Scheduled Intents on the Aggregate (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-scheduled-intents-fnd` + +- `cancel` accepts `cancelMode ∈ {immediate, end_of_term, at(date)}`; `suspend` MAY carry `resumeAt` ([`../PRD.md`](../PRD.md) §6.1; SUB-D-01). +- A non-immediate intent is a `ScheduledIntent` **on the aggregate**: visible to the renewal job (a pending end-of-term cancel MUST suppress renewal attempts + next-term recurring), auditable, and **un-schedulable** until effective; scheduling and un-scheduling both emit events. +- At `effectiveAt` the real transition runs through the full §3.6 guard set — a scheduled intent is not a pre-authorised bypass of Policy/OSS. +- Ramp steps (slice 03, SUB-D-04) are `ScheduledIntent`s of `changePlan`/`updateQuantity` kind; the Foundation provides the scheduling mechanism, Contracts authors the schedule. + +### 4.4 Activation Instants Trio (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-activation-instants-fnd` + +- The aggregate records three instants: `contractEffectiveAt` (booking; **referenced from the Contract** — booking semantics stay Contracts/Finance SoR), `serviceActivatedAt` (stamped at the `activate` commit), `customerAcceptedAt` (stamped by an optional **acceptance-confirmation** operation where Contract clauses require it; absent clauses ⇒ equals service activation) ([`../PRD.md`](../PRD.md) §6.1; SUB-D-05, SEAMS **SUB-C4**). +- **No interim statuses**: pending-activation / pending-acceptance are rejected — `draft` covers the pre-activation window. +- All three ride the lifecycle events + ASC input hooks; recognition semantics stay Finance/Billing. The trio is **not** a `pricingSnapshotRef` segment (SEAMS **SUB-R2**) — it travels on events/read-models. +- **Open**: the acceptance-confirmation flow shape (who confirms, evidence) is Design ([`../PRD.md`](../PRD.md) §15). + +### 4.5 Fail-Closed Policy and OSS Gate (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-gate-fnd` + +- Every **resource-affecting** transition (`activate`, `suspend`, `resume`, `cancel`, Policy-gated plan/add-on/quantity changes) passes the Policy pre-check before commit; **deny or Policy unavailability ⇒ no state change** + audited attempt ([`../PRD.md`](../PRD.md) §6.1 AC 1; SEAMS **SUB-E1**). +- OSS provisioning is coordinated via work orders **confirmed by events**; BSS never mutates OSS resource topology directly ([`../PRD.md`](../PRD.md) §6.4; SEAMS **SUB-E2**). Edges requiring provisioning confirmation run **asynchronously** (§3.6 async note): the intent commits (`approved`, work order issued), the status edge commits on confirmation; on timeout the request fails `oss_unconfirmed` and the work order is idempotently cancelled; a late confirmation is reconciled with a compensating deprovision + audit — never an orphaned resource. +- The gate is the same for a scheduled intent firing at its `effectiveAt` (§4.3) — no pre-authorisation bypass. + +### 4.6 Manifest Alignment of New Transition Types (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-manifest-alignment-fnd` + +- `updateQuantity` (slice 03, SUB-D-02), `convertTrial` (slice 06, gap G-2), the SUB-D-08 completion set (`renew`, `unschedule`, `pauseCollection`, `resumeCollection`, `confirmAcceptance`, `extendTrial`), and `archive` (the `cancelled→archived` edge operation — §4.1) extend the manifest §4.3 `TransitionRequest.type` list; the scheduled-intent envelope (`cancelMode`, `resumeAt`) extends the change vocabulary. Manifest alignment is tracked in [`../PRD.md`](../PRD.md) §15 (SEAMS **SUB-N1**). +- **Consumer obligation**: downstream consumers keying on `TransitionRequest.type` MUST tolerate the new values (route/ignore, not fail-closed) before the manifest lands — the type set is additive, and this gear is the producer. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.1 (`fr-status-enum`, `fr-transitions-guards`, `fr-trials-not-a-status`, `fr-transition-request`, `fr-scheduled-intents`, `fr-activation-instants`), §6.2 (`fr-monotonic-version`), §6.7 (`fr-event-ordering`), §6.1 AC 1, §7.1 (NFRs), §15 (acceptance-flow + manifest-alignment opens). +- **Seams**: **SUB-E1** (fail-closed gate), **SUB-C4** (activation trio), **SUB-N1** (manifest alignment); consumes the ordering half of **SUB-R1** — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: SUB-D-01 (scheduled intents), SUB-D-05 (activation trio), SUB-D-03 (pause posture attribute) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0001`](../ADR/0001-cpt-cf-bss-subscriptions-adr-manifest-closed-status-machine.md) (manifest-closed status machine), [`../ADR/0003`](../ADR/0003-cpt-cf-bss-subscriptions-adr-scheduled-intents-on-aggregate.md) (scheduled intents on the aggregate). +- **Downstream slices**: [`02-composition-versioning.md`](./02-composition-versioning.md) (version + composition), [`03-plan-changes.md`](./03-plan-changes.md) (change envelope + ramps), [`04-suspension-renewal-grace.md`](./04-suspension-renewal-grace.md) (intent visibility, OSS pause), [`05-entitlements.md`](./05-entitlements.md) (issue/revoke at commit), [`06-trials.md`](./06-trials.md) (convertTrial, phase), [`07-tenancy-transfer.md`](./07-tenancy-transfer.md) (delegation, transfer approval), [`08-events-billing.md`](./08-events-billing.md) (outbox, ordering), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (REST + gate contracts). diff --git a/gears/bss/subscriptions/docs/design/02-composition-versioning.md b/gears/bss/subscriptions/docs/design/02-composition-versioning.md new file mode 100644 index 000000000..c70dd36bb --- /dev/null +++ b/gears/bss/subscriptions/docs/design/02-composition-versioning.md @@ -0,0 +1,231 @@ + + + +# DESIGN — Composition & Versioning (Slice 2) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-composition` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Effective-Dated Intervals (normative)](#41-effective-dated-intervals-normative) + - [4.2 Snapshot Segment and Reproducibility (normative)](#42-snapshot-segment-and-reproducibility-normative) + - [4.3 PlanTier Derivability (normative)](#43-plantier-derivability-normative) + - [4.4 Per-Sale Brand Attribution (normative)](#44-per-sale-brand-attribution-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice owns **what applied when**: the effective-dated composition (`PlanLink`/`AddOn` +intervals), the monotonic `version` lineage, and the snapshot discipline that lets rating and +Billing agree on the offer in force for any interval. Composition is **interval algebra over frozen +catalog references** — Subscriptions never edits history destructively; a change opens a new interval +and closes the prior one ([`../PRD.md`](../PRD.md) §6.2). The slice runs through the Foundation +commit path (slice 01); it adds no side door. + +Its cross-gear surface is two seams: **SUB-R2** (Subscriptions writes only the `(currency, region)` +segment of the composed `pricingSnapshotRef` at activation; rating is the composition SoR that seals +the ref) and **SUB-G2** (effective `PlanTier` is derivable @ `t` from the published registry facts, +read-only). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-effective-dated-composition` | `PlanLink (subscriptionId, planId, effectiveFrom, effectiveTo)` + `AddOn (startDate, endDate)` as **half-open UTC intervals**; a change opens/closes intervals, never rewrites them (§4.1). | +| `cpt-cf-bss-subscriptions-fr-monotonic-version` | Each commercial-meaning change increments `version` and appends an immutable `SubscriptionRevision` (mechanic in slice 01 §3.7); composition changes are the primary version driver (§4.1). | +| `cpt-cf-bss-subscriptions-fr-snapshot-discipline` | Fee artifacts carry `pricingSnapshotRef`; Subscriptions freezes the `(currency, region)` binding at activation into the composed ref so Billing never re-resolves mutable catalog for posted periods (§4.2). | +| `cpt-cf-bss-subscriptions-fr-plantier-derivability` | Effective `PlanTier` is a pure function of SKU/Plan @ event time; changes are effective-dated + Policy-gated (§4.3). | +| `cpt-cf-bss-subscriptions-fr-sale-brand-attribution` | The per-sale `brandId` is a Subscriptions attribute published in the evaluation context for brand-scoped overlay matching in rating (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Interval store + read projections | Composition read (effective `PlanLink`/`AddOn` @ `t`) p95 < 200ms via an indexed interval query | Load test; baseline (workshop-pending [`../PRD.md`](../PRD.md) §7.1) | +| `cpt-cf-bss-subscriptions-nfr-horizontal-partitioning` | Interval store | Tenant-partitioned; bulk roll-up read models for account views | Design + load test | + +#### Key ADRs + +No slice-local ADR; the snapshot split is governed by SEAMS **SUB-R2** (rating owns the composed ref; +Subscriptions writes one segment) and the versioning mechanic by slice 01. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-cmp` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Composition mutation (open/close intervals) via the Foundation commit path; snapshot-segment stamping at activation | Rust module in the `subscriptions` gear | +| Domain | `PlanLink`/`AddOn` interval value objects, `PlanTier` resolution, `pricingSnapshotRef` segment | Rust; GTS + Rust domain structs | +| Infrastructure | Interval tables + projected composition read model | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Composition is append-only interval algebra + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-append-only-intervals-cmp` + +A composition change **opens a new interval and closes the prior** at the boundary; historical +intervals are immutable. Rating and Billing read the interval in force @ `t` ([`../PRD.md`](../PRD.md) +§6.2). + +#### One segment, not the whole ref + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-one-segment-cmp` + +Subscriptions writes **only** the `(currency, region)` binding into the composed `pricingSnapshotRef`; +the ref is rating's composition SoR. Subscriptions never mints or seals the whole ref (SEAMS +**SUB-R2**). + +### 2.2 Constraints + +#### Frozen catalog references only + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-frozen-refs-cmp` + +`PlanLink`/`AddOn` reference **published** catalog keys resolved at the boundary; a posted period +reproduces from the frozen ref, never from live catalog state ([`../PRD.md`](../PRD.md) §6.2, §6.8). + +#### PlanTier is derived, never authored + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-plantier-derived-cmp` + +Effective `PlanTier` is derived from the registry-published SKU/Plan; this gear neither authors nor +overrides the taxonomy (SEAMS **SUB-G2**). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-cmp` + +- **`PlanLink`** — `(subscriptionId, planId, effectiveFrom, effectiveTo)`, half-open UTC; the interval a plan applies for rating/billing. +- **`AddOn`** — an optional add-on with `startDate`/`endDate`; same interval discipline. +- **`pricingSnapshotRef` segment** — the Subscriptions-written `(currency, region)` binding frozen at activation, contributed to the rating-sealed composed ref (SEAMS **SUB-R2**; [rating PRD](../../../rating/docs/PRD.md) §1.4). +- **`PlanTierResolution`** — the effective `PlanTier` @ `t`, derived from the published SKU/Plan. +- **`brandId`** — per-sale storefront brand attribute (§4.4). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-composition-cmp` + +- **`CompositionManager`** — opens/closes `PlanLink`/`AddOn` intervals through the Foundation commit path; guarantees no interval overlap or destructive edit. +- **`SnapshotSegmentStamper`** — freezes the `(currency, region)` binding at activation and contributes the segment to the composed ref. +- **`PlanTierResolver`** — resolves effective `PlanTier` @ `t` from published catalog/registry facts. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-composition-cmp` + +The composition **read-model** (effective `PlanLink`/`AddOn` intervals, `PlanTier` @ `t`, active +phase @ `t`) is the rating input contract; its shape is owned by +[`09-consumer-contracts.md`](./09-consumer-contracts.md) (SUB-R1). This slice fixes the interval +semantics and the snapshot segment; wire mappings are Design. + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (commit path, versioning); +feeds [`03-plan-changes.md`](./03-plan-changes.md) (the boundary opens/closes intervals), +[`06-trials.md`](./06-trials.md) (phase intervals), and [`08-events-billing.md`](./08-events-billing.md) +(composition-changing events). + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Pricing | Published `planId`/`PriceWindow` linkage `PlanLink` resolves against | [`../PRD.md`](../PRD.md) §9; SEAMS **SUB-P5** | +| Registry | Published `skuId`/`PlanTier` taxonomy | SEAMS **SUB-G2** | +| Rating | Consumes the composition read model + seals the composed `pricingSnapshotRef` | SEAMS **SUB-R2**, **SUB-R1** | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-open-interval-cmp` + +**Composition change**: at the change boundary (slice 03), `CompositionManager` closes the prior +`PlanLink`/`AddOn` interval `effectiveTo = boundary` and opens the new one `effectiveFrom = boundary` +in one Foundation commit; `version` increments; a composition-changing event is emitted (slice 08). +At activation, `SnapshotSegmentStamper` freezes the `(currency, region)` segment. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-composition-cmp` + +Owned here: `plan_link` and `add_on` interval tables (tenant-partitioned by the pinned +`orderingTenantId`, UTC), plus the `quantity_interval` history (the effective-dated committed seat +count, written by slice 03) and the projected `composition_read_model`. **No-overlap exclusion keys +(2026-07-15 review fix):** `plan_link` per `(subscriptionId)` — one plan in force at a time; +`add_on` per `(subscriptionId, addOnId)` — concurrent *different* add-ons legitimately overlap in +time, only the same add-on may not self-overlap; `quantity_interval` per `(subscriptionId)`. The +`(currency, region)` segment rides the aggregate; the composed ref lives in the fee artifact +(rating/Billing). Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-cmp` + +No slice-specific topology beyond the Foundation's; the composition read model is a projection served +off the commit path ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) §3.8). + +## 4. Additional Context + +### 4.1 Effective-Dated Intervals (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-intervals-cmp` + +- `PlanLink`/`AddOn` are **half-open UTC intervals**; a change closes the prior interval at the boundary and opens a new one — never a destructive edit of history ([`../PRD.md`](../PRD.md) §6.2). +- **Past is immutable; future is replaceable.** Intervals whose `effectiveFrom` has not been reached (a scheduled next-cycle/end-of-term change writes them ahead) MAY be voided or replaced by the owning `unschedule`/superseding change until they take effect — that is an edit of the *future*, evented and audited, never of history. Once `effectiveFrom` passes, the interval is history and immutable. +- Rating and Billing resolve the interval **in force @ `t`**; the boundary instant is the one the change owner (slice 03) sets, shared with rating slicing (SUB-R1). The same @-`t` discipline covers the **committed quantity** (`quantity_interval`, slice 03) — `quantity @ t` is part of the rating read model (PRD §9.2). +- Each committed composition change increments `version` (slice 01 mechanic) for optimistic concurrency + audit lineage. + +### 4.2 Snapshot Segment and Reproducibility (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-snapshot-segment-cmp` + +- Subscriptions freezes the `(currency, region)` binding at **activation** and contributes it as **one segment** of the composed `pricingSnapshotRef`; rating is the composition SoR that seals the ref (SEAMS **SUB-R2**; [rating PRD](../../../rating/docs/PRD.md) §1.4). The segment **persists across renewals** (a manual renewal's "fresh snapshot refs" refresh the pricing-side segments only) and across ownership transfers; only **cancel+new** re-freezes it — which is exactly why cross-currency/region changes are cancel+new (slice 03 §4.3). +- Seat-count provenance (slice 03) and the activation date-trio (slice 01 §4.4) are **not** snapshot segments — they ride events/read-models. No fourth Subscriptions segment appears without a co-decision with rating. +- Posted fee artifacts reproduce from the frozen ref; a posted period never re-resolves live catalog ([`../PRD.md`](../PRD.md) §6.8). + +### 4.3 PlanTier Derivability (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-plantier-cmp` + +- Effective `PlanTier` MUST be derivable from the published SKU/Plan @ event time; a non-derivable tier fails the guard fail-closed (rating resolves tier-dependent pricing @ `t`) ([`../PRD.md`](../PRD.md) §6.2). +- `PlanTier` changes are effective-dated + Policy-gated; the taxonomy itself is registry-owned (SEAMS **SUB-G2**). + +### 4.4 Per-Sale Brand Attribution (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-brand-cmp` + +- A subscription created under a storefront brand records the per-sale `brandId` and publishes it in the pricing evaluation context so rating matches **brand-scoped** overlays ([rating PRD](../../../rating/docs/PRD.md) §17.1 step 4). +- The registry `Product` declares brand *membership*; the **per-sale** `brandId` is a Subscriptions attribute — the one piece of brand context only this gear can supply ([`../PRD.md`](../PRD.md) §6.2). + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.2 (`fr-effective-dated-composition`, `fr-monotonic-version`, `fr-snapshot-discipline`, `fr-plantier-derivability`, `fr-sale-brand-attribution`), §6.8 (reproducibility), §7.1 (NFRs). +- **Seams**: **SUB-R2** (snapshot segment), **SUB-G2** (PlanTier derivability); feeds **SUB-R1** (composition read model) — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (commit + version), [`03-plan-changes.md`](./03-plan-changes.md) (boundary), [`06-trials.md`](./06-trials.md) (phase intervals), [`08-events-billing.md`](./08-events-billing.md) (events), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (read-model contract). diff --git a/gears/bss/subscriptions/docs/design/03-plan-changes.md b/gears/bss/subscriptions/docs/design/03-plan-changes.md new file mode 100644 index 000000000..42631333f --- /dev/null +++ b/gears/bss/subscriptions/docs/design/03-plan-changes.md @@ -0,0 +1,278 @@ + + + +# DESIGN — Plan & Quantity Changes (Slice 3) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-plan-changes` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 The WHEN/MATH Split (normative)](#41-the-whenmath-split-normative) + - [4.2 Up/Down Asymmetry and Seat Provenance (normative)](#42-updown-asymmetry-and-seat-provenance-normative) + - [4.3 Plan-Change Classification (normative)](#43-plan-change-classification-normative) + - [4.4 Overlap Cardinality (normative)](#44-overlap-cardinality-normative) + - [4.5 Ramp Execution (normative)](#45-ramp-execution-normative) + - [4.6 Backdating Guard (normative)](#46-backdating-guard-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice owns the **change boundary and mode** — the WHEN of every commercial mutation +(`changePlan`, `addAddOn`/`removeAddOn`, `updateQuantity`) — and nothing about the money. It sets +`changeEffectiveAt` and `changeMode`, opens/closes composition intervals at that instant (slice 02), +and emits `SubscriptionPlanChanged` (or the composition-changing quantity event) carrying the +boundary inputs; the **rating gear** prorates the recurring component and slices usage at the same +instant ([`../PRD.md`](../PRD.md) §6.3). One boundary owner + one math owner is the split that keeps +replay deterministic. + +Five seams meet here: **SUB-R1** (the WHEN/MATH split + shared boundary), **SUB-R3** (seat-count +provenance and the mid-period seat boundary), **SUB-P1** (plan-change classification adopted from +pricing), **SUB-C2** (ramp execution of Contract-authored schedules), and **SUB-G1** (the overlap +cardinality key from the registry). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-plan-change-boundary` | Subscriptions sets `changeEffectiveAt`/`changeMode ∈ {immediate, next-cycle, end-of-term}` and emits `SubscriptionPlanChanged`; the boundary opens/closes `PlanLink` intervals (§4.1). | +| `cpt-cf-bss-subscriptions-fr-proration-ownership` / `cpt-cf-bss-subscriptions-fr-proration-triggers` | Rating prorates `planA` over `[periodStart, changeEffectiveAt)` and `planB` over `[changeEffectiveAt, periodEnd)`; Subscriptions never computes day-count math — it fixes the trigger + `effectiveFrom` + "no posted-invoice mutation" (§4.1). | +| `cpt-cf-bss-subscriptions-fr-update-quantity` | `updateQuantity` is a first-class transition with the change envelope; seat counts rating reads originate only from **committed** transitions; up increases MAY be immediate, decreases default `next-cycle` (§4.2; SUB-D-02). | +| `cpt-cf-bss-subscriptions-fr-ramp-execution` | A Contract-authored ramp materialises as a sequence of scheduled `changePlan`/`updateQuantity` intents executed via the slice-01 `IntentScheduler` (§4.5; SUB-D-04). | +| `cpt-cf-bss-subscriptions-fr-overlap-cardinality` | On `activate`, evaluate `overlapScopeKey` + `maxConcurrentActive`; reject/queue fail-closed where the rule would break idempotent billing (§4.4). | +| `cpt-cf-bss-subscriptions-fr-backdated-changes` | A backdated `effectiveFrom` inside a posted invoice period is rejected → adjustment path; operational backdating emits an explicit audit reason (§4.6). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-lifecycle-latency` | Change commit path | `changePlan`/`updateQuantity` in the synchronous commit class (p95 < 1s) | Load test; baseline (workshop-pending) | +| `cpt-cf-bss-subscriptions-nfr-proration-accuracy` | Boundary + rating | 100% end-to-end alignment vs policy; math owned by rating, boundary here — tested on the joint fixture | Joint proration fixture | + +#### Key ADRs + +| ADR ID | Decision Summary | +|--------|------------------| +| [`../ADR/0002`](../ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md) `cpt-cf-bss-subscriptions-adr-when-not-math-split` | Subscriptions owns only the change boundary/mode; rating owns all proration math (§4.1). | + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-chg` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Boundary/mode setting, classification enforcement, quantity provenance, ramp intent execution, overlap detection | Rust module in the `subscriptions` gear | +| Domain | Change envelope, `overlapScopeKey`, seat-provenance record | Rust; GTS + Rust domain structs | +| Infrastructure | None beyond the Foundation stores + `scheduled_intent` (slice 01) | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### WHEN, never math + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-when-not-math-chg` + +Subscriptions sets `changeEffectiveAt`/`changeMode` and emits them; it computes no proration, tax, or +FX. Rating owns the arithmetic at the same boundary ([`../PRD.md`](../PRD.md) §6.3; SEAMS **SUB-R1**). + +#### Provenance before the number + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-provenance-chg` + +Any quantity rating reads (`quantitySource = subscription_seat_count`) originates from a **committed +`updateQuantity` transition** — never an untyped attribute edit — so the seat count has an auditable +boundary and Policy gate (§4.2; SEAMS **SUB-R3**). + +### 2.2 Constraints + +#### No posted-invoice mutation + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-no-posted-mutation-chg` + +Proration surfaces as **new billable or adjusting artifacts** (Billing), never an edit of a posted +invoice line ([`../PRD.md`](../PRD.md) §6.3, §6.8). + +#### Classification is adopted, not derived + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-classification-adopted-chg` + +Comparability / allowed change targets come from the pricing consumer contract; Subscriptions +**enforces** the classification, it does not re-derive it. Cross-currency/region/frequency = +cancel+new (§4.3; SEAMS **SUB-P1**). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-chg` + +- **`ChangeEnvelope`** — `changeEffectiveAt` (UTC), `changeMode ∈ {immediate, next-cycle, end-of-term}`, the up/down asymmetry policy, source `TransitionRequest`. +- **`QuantityInterval`** — the **effective-dated committed seat count** (`quantity`, `effectiveFrom`, `effectiveTo`, source `updateQuantity` transition): the only source rating may read, resolvable as `quantity @ t` for replay (2026-07-15 review fix — a single mutable value cannot serve the replay contract; stored in slice 02's `quantity_interval`). +- **`overlapScopeKey`** — default `(payerTenantId, catalogSubscriptionProductKey)` + optional extra dimensions; `maxConcurrentActive` from Catalog/Contract (default 1); **`supersedesSubscriptionId`** — the cancel+new handover linkage (§4.3/§4.4). +- **Ramp step** — a `ScheduledIntent` (slice 01) of `changePlan`/`updateQuantity` kind authored by Contracts. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-plan-changes-chg` + +- **`ChangeBoundaryPlanner`** — resolves `changeEffectiveAt`/`changeMode` (incl. up/down asymmetry) and drives the interval open/close via slice 02. +- **`ChangeClassifier`** — enforces the pricing-published comparability/target rules; routes cross-boundary changes to cancel+new. +- **`QuantityProvenanceRecorder`** — writes the effective-dated `QuantityInterval`, binding each committed seat count to its `updateQuantity` transition. +- **`OverlapDetector`** — evaluates `overlapScopeKey` + `maxConcurrentActive` at `activate`. +- **`RampExecutor`** — materialises Contract-authored ramps as scheduled intents via the Foundation `IntentScheduler`. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-plan-change-chg` + +The `changePlan` / `addAddOn` / `removeAddOn` / `updateQuantity` operations carry the change envelope; +the emitted `SubscriptionPlanChanged` (and the composition-changing quantity event) carry the +boundary inputs for rating/Billing. Preview of proration impact is surfaced via the preview owner +named in Design — the **calculation authority is rating** ([`../PRD.md`](../PRD.md) §10 plan-change UC). +Wire mappings + event field matrix are owned by [`08-events-billing.md`](./08-events-billing.md) / +[`09-consumer-contracts.md`](./09-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (commit path, `IntentScheduler`, +`Approval`) and [`02-composition-versioning.md`](./02-composition-versioning.md) (interval open/close). +Feeds [`08-events-billing.md`](./08-events-billing.md) (change events). + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Pricing | Plan-change classification (`allowedChangeTargets`/`comparabilityRank`) | SEAMS **SUB-P1** | +| Rating | Consumes `(changeEffectiveAt, changeMode)` + `quantity @ t`; owns proration + usage slicing | SEAMS **SUB-R1**, **SUB-R3** | +| Contracts | Authors the committed ramp schedule | SEAMS **SUB-C2** | +| Registry | `catalogSubscriptionProductKey` for `overlapScopeKey` | SEAMS **SUB-G1** | +| Billing | `billedThroughAt` posted-period watermark consumed by the backdating guard (§4.6) | SEAMS **SUB-B6** | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-flow-change-boundary-chg` + +**Plan/quantity change** (refines `cpt-cf-bss-subscriptions-seq-change-boundary`): classify the +target (`ChangeClassifier`; cross-boundary ⇒ cancel+new) → resolve `changeEffectiveAt`/`changeMode` +(up/down asymmetry) → for immediate: close prior / open new interval at `now`; for next-cycle/ +end-of-term: persist a `ScheduledIntent` **and write the future interval** (slice 02 future-replaceable +rule) → emit `SubscriptionPlanChanged` with the boundary inputs → rating slices + prorates. +`updateQuantity` additionally writes the `QuantityInterval` and emits `SubscriptionQuantityChanged`. + +**Event-once convention (2026-07-15 review fix):** the boundary is announced **exactly once** — +`SubscriptionPlanChanged`/`SubscriptionQuantityChanged` is emitted at commit time (immediate) or at +scheduling time (deferred, carrying the future `changeEffectiveAt`); the successful intent **firing +emits no second boundary event** (the transition commit references the announced boundary). An +`unschedule` before the boundary emits `SubscriptionIntentUnscheduled`, which **voids the announced +boundary** for consumers — by construction it always precedes the boundary instant on the ordered +stream. + +**Firing-failure retraction (2026-07-15 review fix).** A deferred boundary is announced *before* it +is committed, so a firing that **fails its guard set at `effectiveAt`** (Policy deny, `guard_violation`, +`oss_unconfirmed` — slice 01 §4.3/§4.5 leave state unchanged) would otherwise strand rating on a +boundary that never materialised. The `IntentScheduler` therefore emits `SubscriptionIntentUnscheduled` +(reason = `firing_failed`, referencing the same intent) on any terminal firing failure, which **voids +the announced boundary** exactly like an operator `unschedule`. This is the one case where the +voiding event lands **at** the boundary instant rather than strictly before it; consumers MUST treat +a boundary as retractable up to and including its `effectiveAt` on the ordered stream, and re-rate/ +un-slice on receipt. The §17.1 charge-coverage reconciliation is the backstop. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-plan-change-chg` + +No new owned store beyond the Foundation's `scheduled_intent` (ramps + deferred changes) and the +slice-02 interval tables — the committed quantity history lives in slice 02's `quantity_interval` +(written by this slice's `updateQuantity`); `overlapScopeKey` (+ `supersedesSubscriptionId`) rides +the aggregate with an `active`-status partial index per key. Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-chg` + +No slice-specific topology; scheduled changes + ramp steps fire via the Foundation `IntentScheduler` +singleton ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) §3.8). + +## 4. Additional Context + +### 4.1 The WHEN/MATH Split (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-when-math-chg` + +- Subscriptions owns `changeEffectiveAt` + `changeMode`; it emits them on `SubscriptionPlanChanged` and never computes proration day-count or override resolution ([`../PRD.md`](../PRD.md) §6.3). +- Rating rates `planA` over `[periodStart, changeEffectiveAt)` and `planB` over `[changeEffectiveAt, periodEnd)` (half-open UTC), prorates on the frozen `prorationBasis`, and slices usage at the same boundary ([rating PRD](../../../rating/docs/PRD.md) §6.11; SEAMS **SUB-R1**). +- Immediate ⇒ delta recurring / one-time true-up (Billing); next-cycle ⇒ first recurring under the new plan at the new period; mid-cycle ⇒ credit/debit notes if an invoice is already posted — **never** an edited posted line ([`../PRD.md`](../PRD.md) §6.3). + +### 4.2 Up/Down Asymmetry and Seat Provenance (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-asymmetry-chg` + +- Default asymmetry (owned here, like plan changes): **increases MAY be immediate** (prorated by rating at the boundary); **decreases default `next-cycle`** ([`../PRD.md`](../PRD.md) §6.3; SUB-D-02). +- The seat count rating reads (`quantitySource = subscription_seat_count`, pricing D-18) MUST come from **committed** `updateQuantity` transitions only — auditable provenance, never an untyped edit — and is stored **effective-dated** (`QuantityInterval`) so `quantity @ t` is resolvable in the read model for any charge instant and replay ([`../PRD.md`](../PRD.md) §6.3, §9.2). +- **Edge guards:** a decrease below the quantity already consumed by committed assignments is rejected unless policy explicitly forces revocation (the entitlement effect routes through slice 05); `quantity = 0` is not a quantity change — submit a `cancel`. **Source of "consumed" (2026-07-15 review fix):** the guard reads the count of **committed entitlement bindings owned here** (slice 05 `GrantSetAssignment` / seat-scoped `Entitlement` rows) — a local, transactionally-consistent number — **not** an external OSS/AMS seat-to-user mapping; where finer per-user seat placement lives in OSS, that enforcement is OSS's (SUB-E3) and the decrease guard binds only to what this gear committed. So the guard never depends on a cross-gear read that could be stale or unavailable. +- **Open (mirrors rating's seat-change boundary transport, rating `design/09` §4.3):** a mid-period seat change is transported as a **Subscriptions-driven change boundary** (default) that rating prorates, not Subscriptions-side proration; pin the default with rating at Design (SEAMS **SUB-R3**). + +### 4.3 Plan-Change Classification (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-classification-chg` + +- The pricing consumer contract publishes `allowedChangeTargets` / `comparabilityRank` / the boundary class; Subscriptions **classifies** upgrade/downgrade/cross and **enforces** the boundary — it does not re-derive comparability ([pricing `design/06`]; SEAMS **SUB-P1**). +- **Cross-currency / cross-region / cross-frequency changes = cancel+new**, not an in-place change (pricing §15 cross-boundary sign-off; the overlap default protects against double `active`, §4.4). +- **Cancel+new mechanics (2026-07-15 review fix).** The replacement is a **saga over two aggregates**, not a transaction: (1) create the successor in `draft` with **`supersedesSubscriptionId`** set; (2) validate its full guard set (Policy, sellability, overlap-with-exemption §4.4) **before** touching the predecessor; (3) schedule the predecessor's cancel and the successor's activation on the **same boundary instant** (entitlement continuity across the handover — slice 05 re-issue at one boundary); (4) on any successor failure the predecessor is untouched (compensation = void the successor draft). The successor re-freezes the `(currency, region)` segment (slice 02 §4.2). The customer is never left without the predecessor until the successor has passed its guards. + +### 4.4 Overlap Cardinality (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-overlap-chg` + +- Default: at most **one `active`** per `overlapScopeKey = (payerTenantId, catalogSubscriptionProductKey)`; `catalogSubscriptionProductKey` is **registry-owned** — Design binds the stored field to a published SKU/product key (SEAMS **SUB-G1**, PR #4177). +- Multiple concurrent `active` are allowed when they differ on `overlapScopeKey` (extra dimensions) or when Catalog/Contract sets `maxConcurrentActive > 1`; else `maxConcurrentActive = 1` ([`../PRD.md`](../PRD.md) §6.3). +- **Detection runs on every entry into `active`** — `activate` **and** `resume` — **and on every committed change that mutates the key**: a `changePlan` that alters `catalogSubscriptionProductKey` and an ownership `transfer` that alters `payerTenantId` (slice 07) re-evaluate before commit (2026-07-15 review fix; detection-on-activate-only left three bypasses). The rule **rejects or queues fail-closed** when it would break idempotent billing. +- **Supersedes exemption:** a successor carrying `supersedesSubscriptionId` (§4.3 cancel+new) is exempt from the rule against exactly its predecessor, only until the predecessor's scheduled end — the handover window is not a violation. +- The `overlapScopeKey` is indexed (`active`-status partial index per key) so the check is a point lookup at 100K+ subscriptions/tenant, not a scan. + +### 4.5 Ramp Execution (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-ramp-chg` + +- A committed multi-step ramp is a **Contract term**: Contracts authors + owns the schedule; it materialises here as a sequence of scheduled `changePlan`/`updateQuantity` intents, each executed with the normal envelope + guards + idempotency (§4.1; slice 01 `IntentScheduler`) ([`../PRD.md`](../PRD.md) §6.3; SUB-D-04). +- **Mid-ramp failure:** a step that fails its guard set at firing (e.g. Policy deny) **halts the remaining steps** — they park `suspended pending re-authoring`, an auditable failure event is emitted, and the Contracts owner is signalled to amend or re-confirm the schedule; the executed prefix stands (committed transitions are not rolled back). +- **No native schedule aggregate** at launch; atomic multi-action submission (Zuora-Orders-style) is a Contracts/Design follow-up (SEAMS **SUB-C2**, [`../PRD.md`](../PRD.md) §15). + +### 4.6 Backdating Guard (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-backdating-chg` + +- A commercial `effectiveFrom` in the past is allowed only when contract/catalog rules allow and **no posted invoice** would be contradicted; a backdated boundary inside a posted period is **rejected** with a clear reason → the adjustment path ([`../PRD.md`](../PRD.md) §6.3 AC 6). +- **Data source (2026-07-15 review fix):** "posted" is evaluated against the Billing-supplied per-subscription **`billedThroughAt`** watermark (read model/event — SEAMS **SUB-B6**); the guard is **fail-closed**: an unknown/stale watermark is treated as posted and the change rejected toward the adjustment path. +- Operational backdating (e.g. an entitlement start in the past) emits an explicit audit reason and MAY require Policy re-evaluation. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.3 (`fr-plan-change-boundary`, `fr-proration-triggers`, `fr-proration-ownership`, `fr-backdated-changes`, `fr-overlap-cardinality`, `fr-update-quantity`, `fr-ramp-execution`), §6.8 (no-retro), §10 (plan-change UC), §7.1 (NFRs), §15 (seat-boundary + ramp opens). +- **Seams**: **SUB-R1**, **SUB-R3**, **SUB-P1**, **SUB-C2**, **SUB-G1** — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: SUB-D-02 (`updateQuantity`), SUB-D-04 (ramps) — [`../DECISIONS.md`](../DECISIONS.md). +- **ADR**: [`../ADR/0002`](../ADR/0002-cpt-cf-bss-subscriptions-adr-when-not-math-split.md) (WHEN/MATH split). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (envelope, scheduler), [`02-composition-versioning.md`](./02-composition-versioning.md) (intervals), [`08-events-billing.md`](./08-events-billing.md) (change events), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (rating read-model). diff --git a/gears/bss/subscriptions/docs/design/04-suspension-renewal-grace.md b/gears/bss/subscriptions/docs/design/04-suspension-renewal-grace.md new file mode 100644 index 000000000..21166cc5c --- /dev/null +++ b/gears/bss/subscriptions/docs/design/04-suspension-renewal-grace.md @@ -0,0 +1,258 @@ + + + +# DESIGN — Suspension, Renewal & Grace (Slice 4) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-suspension-renewal-grace` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Suspend / Resume and OSS Pause (normative)](#41-suspend--resume-and-oss-pause-normative) + - [4.2 Billing-Only Pause Posture (normative)](#42-billing-only-pause-posture-normative) + - [4.3 Renewal Evaluation, Auto vs Manual (normative)](#43-renewal-evaluation-auto-vs-manual-normative) + - [4.4 Grace Ladder and Policy (normative)](#44-grace-ladder-and-policy-normative) + - [4.5 Notices and Opt-Out (normative)](#45-notices-and-opt-out-normative) + - [4.6 Dunning Handoff (normative)](#46-dunning-handoff-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice owns the **posture and time-driven** transitions: suspend/resume, the billing-only +`collectionPaused` posture, and the renewal → grace ladder. All of them route through the Foundation +commit path and are **governed, reversible, auditable** — never soft deletes or silent state drift +([`../PRD.md`](../PRD.md) §6.4, §6.5). The renewal job is Contract-driven: Subscriptions executes and +audits; the commercial terms (grace length, ladder, regional templates) are Contract SoR. + +The load-bearing risk (**SUB-C1**) is that the upstream Contracts PRD does **not yet author** the +renewal/grace SoR; until it does, the **platform defaults govern** (7-day grace, 30/14/7/1 notices, +hybrid exit). The slice also owns **SUB-B2** (`collectionPaused` artifact treatment with Billing), +**SUB-F1** (Payments signals), **SUB-B5** (dunning handoff), and **SUB-E2** (OSS pause on suspend). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-suspend-resume` | `suspend`/`resume` through the Foundation gate; revoke/freeze then re-issue entitlements (slice 05); OSS deprovision/pause / reprovision confirmed by events (§4.1). | +| `cpt-cf-bss-subscriptions-fr-suspension-billing-posture` | Suspension-vs-billing (pause recurring vs continue for reserved capacity) is explicit product policy in subscription attributes + contract clauses (§4.1). | +| `cpt-cf-bss-subscriptions-fr-collection-pause` | `collectionPaused` is an auditable window (start/end/limit/reason) **posture on `active`** — service untouched, recurring emission suppressed/deferred per policy (§4.2; SUB-D-03). | +| `cpt-cf-bss-subscriptions-fr-renewal-evaluation` / `cpt-cf-bss-subscriptions-fr-renewal-auto-manual` | A renewal job evaluates term/`endDate`, extends on success, triggers the failed path on failure; auto requires a valid payment method + contract allow; manual is an explicit `TransitionRequest`; attempts keyed against double extension (§4.3). | +| `cpt-cf-bss-subscriptions-fr-failed-renewal-ladder` / `cpt-cf-bss-subscriptions-fr-grace-policy` | A testable grace ladder: 7-day default, paused next-term recurring, evaluated fields stored for replay, hybrid exit (elapsed OR retry-exhausted) (§4.4). | +| `cpt-cf-bss-subscriptions-fr-renewal-notices` | Notice triggers at 30/14/7/1 days (Contract/template override within Legal bounds); opt-out = scheduled non-renewal at term end; delivery = Notifications (§4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-lifecycle-latency` | Suspend/resume commit path | Synchronous commit class (p95 < 1s) | Load test; baseline (workshop-pending) | +| `cpt-cf-bss-subscriptions-nfr-recurring-cut` | Grace recurring suppression | Blocked next-term recurring is not emitted until renewal succeeds or grace fails | Reconciliation §17.1 | + +#### Key ADRs + +No slice-local ADR; the renewal/grace SoR split is governed by SEAMS **SUB-C1** (Contracts SoR; +platform default until authored) and the pause posture by SUB-D-03. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-rnw` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Suspend/resume + pause posture handlers; the renewal job; notice + grace ladder | Rust module in the `subscriptions` gear | +| Domain | `collectionPaused` window, renewal-evaluation record (evaluated fields), grace-ladder state, notice schedule | Rust; GTS + Rust domain structs | +| Infrastructure | Grace-evaluation table; renewal job coordinated via the lease library | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Governed, reversible posture + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-governed-posture-rnw` + +Suspension and pause are governed, reversible posture changes — not soft deletes; every transition +is Policy-gated, evented, and audited ([`../PRD.md`](../PRD.md) §6.4). + +#### Contract is the terms SoR; Subscriptions executes + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-contract-sor-rnw` + +Renewal terms, grace length, ladder, and regional templates are Contract SoR; Subscriptions runs the +job, stores **evaluated fields** at evaluation time, and audits — it invents no commercial term +([`../PRD.md`](../PRD.md) §6.5; SEAMS **SUB-C1**). + +### 2.2 Constraints + +#### Grace defaults are platform-testable + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-grace-defaults-rnw` + +Until Contracts authors the SoR, the **7-day grace / 30-14-7-1 notices / hybrid exit** platform +defaults govern and MUST be product-testable; Contract/template override only within Legal bounds +([`../PRD.md`](../PRD.md) §6.5; SEAMS **SUB-C1**). + +#### Idempotent renewal attempts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-renewal-idempotent-rnw` + +Renewal attempts are keyed to prevent **double term extension**; a retry-driven job never extends +twice ([`../PRD.md`](../PRD.md) §6.5). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-rnw` + +- **`CollectionPauseWindow`** — `start`, `end`/`limit`, `reason`; posture on `active`, collection-scoped only. +- **`RenewalEvaluation`** — the evaluated fields at term-end (grace length, ladder variant, billing posture, `graceEndsAt`) frozen for replay + idempotent jobs. +- **`GraceLadderState`** — grace start, elapsed/retry status, exit trigger. +- **`NoticeSchedule`** — the 30/14/7/1 trigger set + opt-out flag. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-renewal-rnw` + +- **`SuspendResumeHandler`** — the posture transitions through the Foundation gate; coordinates OSS pause/reprovision + entitlement freeze/re-issue (slice 05). +- **`CollectionPauseHandler`** — sets/clears the `collectionPaused` window; signals Billing the artifact treatment. +- **`RenewalJob`** — the coordinated singleton evaluating term/`endDate`, extending on success, entering the failed path on failure. +- **`GraceLadder`** — drives the 7-day (or Contract) window, the paused next-term recurring, and the hybrid exit. +- **`NoticeScheduler`** — emits notice triggers + processes opt-out as scheduled non-renewal. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-renewal-rnw` + +`suspend`/`resume`/`renew` operations + the `collectionPaused` set/clear operation; renewal-monitoring +read models (failing renewals, `graceEndsAt`, ladder variant) power the Finance UC +([`../PRD.md`](../PRD.md) §10 renewal-monitoring). Payments failure-signal + Billing dunning wire +contracts are owned by [`09-consumer-contracts.md`](./09-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (commit path, gate, +`IntentScheduler` for scheduled non-renewal) and [`05-entitlements.md`](./05-entitlements.md) +(freeze/re-issue on suspend/resume). Feeds [`08-events-billing.md`](./08-events-billing.md) +(suspend/resume/collection events). + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Contracts | Renewal terms, grace ladder, regional templates, `PriceOverride` | SEAMS **SUB-C1**, **SUB-C5** | +| Payments | Pre-check outcomes + retry-exhaustion declarations | SEAMS **SUB-F1** | +| Billing | Dunning execution; `collectionPaused` artifact treatment | SEAMS **SUB-B2**, **SUB-B5** | +| OSS | Deprovision/pause on suspend; reprovision on resume | SEAMS **SUB-E2** | +| Notifications | Notice + win-back delivery (triggers owned here) | SEAMS **SUB-F2** | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-renewal-grace-rnw` + +**Renewal + grace** (refines `cpt-cf-bss-subscriptions-seq-renewal-grace`): `RenewalJob` at term end +→ payment pre-check → **success**: extend term + fresh snapshot refs (keyed against double extension); +**failure**: `GraceLadder` starts (7-day default), the blocked next-term recurring is **paused** +(not emitted), notices fire, hybrid exit (interval elapsed OR Payments retry-exhausted) → `suspended`/ +`cancelled` per Contract ladder; all transitions run through the Foundation gate. `RenewalEvaluation` +stores evaluated fields for replay. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-renewal-rnw` + +Owned here: `renewal_evaluation` (evaluated fields, `graceEndsAt`), `grace_ladder_state`, and the +`collection_pause_window` rows on the aggregate. Scheduled non-renewal rides the Foundation +`scheduled_intent`. Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-rnw` + +The `RenewalJob` + `NoticeScheduler` run as coordinated singletons **per tenant partition** via the +lease library — one lease per `orderingTenantId` shard, shard-parallel across partitions, so the +100K+/tenant scale target is not funnelled through one global instance (2026-07-15 review fix); +within a partition the per-aggregate ordering holds. **Intra-tenant parallelism (2026-07-15 review +fix):** because a single large tenant is one `orderingTenantId` shard, the daily-cut-class work +inside it is further sub-sharded by a stable hash of `subscriptionId` into N worker leases, so a +100K+/tenant renewal/notice sweep is not serialised through one worker; per-aggregate ordering is +preserved because a given `subscriptionId` always maps to the same sub-shard. N is a deploy-time +capacity knob, not a commercial one. Suspend/resume are control-plane transitions; +edges with OSS legs follow the Foundation async note ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) +§3.6/§3.8). + +## 4. Additional Context + +### 4.1 Suspend / Resume and OSS Pause (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-suspend-resume-rnw` + +- **Suspend** → `suspended`; revoke/freeze entitlements per policy; OSS deprovision/pause confirmed by events. **Resume** → `active`; **Policy allow mandatory**; re-issue entitlements; OSS reprovision ([`../PRD.md`](../PRD.md) §6.4; SEAMS **SUB-E2**). **Resume after a grace-driven suspension additionally requires the blocking payment failure resolved** (successful renewal/payment or an audited operator override) — `resume` alone never restores unpaid service ([`../PRD.md`](../PRD.md) §6.5; 2026-07-15 review fix). Resume also re-runs the overlap check (slice 03 §4.4 — entry into `active`). +- Suspension-vs-billing (pause recurring vs continue for reserved capacity) is **explicit product policy** in subscription attributes + contract clauses — never a silent assumption ([`../PRD.md`](../PRD.md) §6.4). + +### 4.2 Billing-Only Pause Posture (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-collection-pause-rnw` + +- `collectionPaused` is a **posture on `active`**: service + entitlements untouched; the recurring period fact for the paused window is emitted **marked `collectionPaused`** and Billing suppresses/defers the posting **per policy** — Billing owns the artifact treatment, AC 24 "not posted" holds ([`../PRD.md`](../PRD.md) §6.4; SUB-D-03, SEAMS **SUB-B2**). +- **Renewal during the window (SUB-D-12, AC 29):** renewal **evaluation and term extension continue** (the term stays deterministic for rating/Billing), but the payment pre-check, grace entry, and dunning handoff are **suspended** for renewals whose collection falls inside the window; the deferred collection runs when the window ends. A pause never converts into a payment-failure suspension by itself. +- **Pause applied while already in grace (2026-07-15 review fix):** a `pauseCollection` set on a subscription **already inside the grace ladder** (the common dispute/hardship reaction to a failed charge) **freezes the ladder** — the grace clock stops, dunning is held, and no exit to `suspended`/`cancelled` fires — for the duration of the window; on `resumeCollection` (or window end) the ladder resumes with the **remaining** grace time (elapsed time before the pause counts, time inside it does not). This is the same "collection deferred, service preserved" invariant as SUB-D-12 applied to an in-flight ladder rather than a fresh renewal; the pause-day limit (§15, Product/Billing) bounds indefinite deferral so a pause cannot be used to escape suspension forever. `GraceLadderState` records the freeze/thaw as evaluated fields for replay. +- The posture is an auditable window (start/end/limit/reason) bounded by Contract/Policy, set/cleared by the `pauseCollection`/`resumeCollection` transitions (SUB-D-08). **Open (§15):** pause-day limits + resume proration — Product/Billing. + +### 4.3 Renewal Evaluation, Auto vs Manual (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-renewal-rnw` + +- The renewal job evaluates `Renewal` (`autoRenew`, term windows) from Contract; **auto** extends when the payment method is valid + contract allows; **manual** requires an explicit `TransitionRequest` of type **`renew`** (SUB-D-08) with the same idempotency rules ([`../PRD.md`](../PRD.md) §6.5). +- Attempts are **keyed to prevent double term extension**: the term-extension effect is idempotent on `(subscriptionId, currentTermSequence)` — the monotonic index of the term being renewed — derived, not client-supplied, so a crashed-and-retried `RenewalJob` firing (or a duplicate manual `renew`) resolves to the **already-extended** term instead of extending twice (the same derive-the-key discipline as the scheduled-intent firing, slice 01 §3.6). Manual renewal creates a new term window with fresh snapshot refs (pricing-side segments only — the `(currency, region)` segment persists, slice 02 §4.2). +- **Late success inside grace:** the new term starts at the **old term end** (backdated — continuous coverage, no gap); the previously blocked next-term recurring fact is emitted with its **original** `(subscriptionId, billing period)` key ([`../PRD.md`](../PRD.md) §6.5 grace policy 5). + +### 4.4 Grace Ladder and Policy (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-grace-rnw` + +- Default grace: **7 calendar days** from grace start (first auditable failed pre-check or aligned post-renewal billing failure); jurisdiction bounds come from Contract/regional template within Legal ([`../PRD.md`](../PRD.md) §6.5). +- Recurring during grace: the **blocked next-term recurring MUST NOT be emitted** until renewal succeeds or grace resolves to failure (paused); usage-rated charges MAY continue until `suspended` unless Contract/Policy freezes them. +- Exit is **hybrid — whichever first**: grace interval elapses, OR Payments declares no further automated retries. Move to `cancelled` only per contract-defined steps after suspend/final dunning ([`../PRD.md`](../PRD.md) §6.5). Subscription stores **evaluated fields** for audit + idempotent jobs + replay (SEAMS **SUB-C1**, **SUB-F1**). + +### 4.5 Notices and Opt-Out (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-notices-rnw` + +- Notice triggers at platform default **30/14/7/1 days** before term end; Contract/regional template MAY override within published bounds. **Triggers + intervals owned here; delivery = Notifications/Comms** ([`../PRD.md`](../PRD.md) §6.5; SEAMS **SUB-F2**). +- **Short terms:** intervals ≥ the term length are skipped — only offsets that fit inside the current term fire (a monthly term gets 14/7/1 by default, not a 30-day notice at term start); the effective set is an evaluated field for audit. +- A renewal **opt-out** is a scheduled non-renewal at term end (cancel at term boundary; no further attempts), idempotent — via the Foundation `IntentScheduler`; opting back in is the `unschedule` of that intent (SUB-D-08). + +### 4.6 Dunning Handoff (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-dunning-rnw` + +- A post-renewal billing failure hands off to **dunning** (Billing/Payments §4.4–4.5); the same grace rules + triggers apply. Subscriptions emits the failure/grace signals + audit trail; **dunning execution + PSP webhook payloads are Billing/Payments + Design** ([`../PRD.md`](../PRD.md) §6.5; SEAMS **SUB-B5**, **SUB-F1**). + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.4 (`fr-suspend-resume`, `fr-suspension-billing-posture`, `fr-collection-pause`), §6.5 (`fr-renewal-evaluation`, `fr-renewal-auto-manual`, `fr-renewal-notices`, `fr-failed-renewal-ladder`, `fr-grace-policy`), §10 (renewal-monitoring UC), §7.1 (NFRs), §15 (pause mechanics open), §16 (Contracts-grace risk). +- **Seams**: **SUB-C1**, **SUB-B2**, **SUB-F1**, **SUB-B5**, **SUB-E2**, **SUB-C5**, **SUB-F2** — [`../SEAMS.md`](../SEAMS.md). +- **Decisions**: SUB-D-03 (`collectionPaused`) — [`../DECISIONS.md`](../DECISIONS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (gate, scheduler), [`05-entitlements.md`](./05-entitlements.md) (freeze/re-issue), [`08-events-billing.md`](./08-events-billing.md) (posture events), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (Payments/Billing contracts). diff --git a/gears/bss/subscriptions/docs/design/05-entitlements.md b/gears/bss/subscriptions/docs/design/05-entitlements.md new file mode 100644 index 000000000..0c43db384 --- /dev/null +++ b/gears/bss/subscriptions/docs/design/05-entitlements.md @@ -0,0 +1,240 @@ + + + +# DESIGN — Entitlement Lifecycle (Slice 5) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-entitlements` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Issue / Revoke from Transitions (normative)](#41-issue--revoke-from-transitions-normative) + - [4.2 Assignment from the Grant Set (normative)](#42-assignment-from-the-grant-set-normative) + - [4.3 Point-of-Use Check and Enforcement Split (normative)](#43-point-of-use-check-and-enforcement-split-normative) + - [4.4 Quota Soft/Hard Limits (normative)](#44-quota-softhard-limits-normative) + - [4.5 Prepaid Grant Hook (deferred)](#45-prepaid-grant-hook-deferred) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice makes entitlement posture a **deterministic function of committed subscription state**: +on every resource-affecting transition and phase boundary it issues or revokes entitlements from the +plan's **published grant set**, and it serves a real-time **point-of-use check decision state** that +OSS enforces. Subscriptions is the entitlement **SoR** (what is granted, quota remaining, limit +state); it **never executes enforcement** ([`../PRD.md`](../PRD.md) §6.9). The check surface is the +predecessor's core promise, held to **p95 < 100ms** ([`../PRD.md`](../PRD.md) §7.1). + +Three seams meet here: **SUB-P2** (assignment from the pricing grant set incl. the per-phase map, +D-41), **SUB-E3** (the check-state ↔ OSS-enforcement split + the quota mid-request open), and +**SUB-B4** (the prepaid-credit hook — definition pricing D-43, drawdown Billing/Rating, GA-gated). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-entitlement-issue-revoke` | On a successful resource-affecting transition (activate/suspend/resume/cancel/gated change) issue or revoke entitlements to match the new posture + emit `EntitlementIssued`/`EntitlementRevoked` (§4.1). | +| `cpt-cf-bss-subscriptions-fr-entitlement-assignment` | On activation, phase boundary, and committed change, assign from the plan's published grant set — incl. the **per-phase map** where phased (pricing D-41) — with effective dates aligned to `changeMode` (§4.2). | +| `cpt-cf-bss-subscriptions-fr-entitlement-check-contract` | A real-time check read contract (flag decision, quota remaining, limit state) at p95 < 100ms, tenant-isolated + cache-friendly; OSS enforces, this gear serves the decision state (§4.3). | +| `cpt-cf-bss-subscriptions-fr-entitlement-quota-limits` | Track usage vs quotas (fed by the rating pipeline); soft limit ⇒ auditable warning; hard limit ⇒ blocking check state; exhaustion/restore evented (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-entitlement-check-latency` | Check read surface | Cache-first projection, tenant-isolated, single indexed read off the hot path — p95 < 100ms | Load test before GA ([`../PRD.md`](../PRD.md) §16) | +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Assignment propagation | Entitlement update reaches the check surface < 5s | Reconciliation §17.1 (entitlement sync) | + +#### Key ADRs + +No slice-local ADR; the assignment source is governed by SEAMS **SUB-P2** (catalog authors, this gear +assigns) and the enforcement split by **SUB-E3**. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-ent` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | The point-of-use check read surface behind the gateway; admin issue/revoke paths | Rust, REST/OpenAPI | +| Application | Assignment from grant sets; issue/revoke on transitions; quota tracking | Rust module in the `subscriptions` gear | +| Domain | `Entitlement` state (flags/quotas/limits), grant-set resolution, quota counters | Rust; GTS + Rust domain structs | +| Infrastructure | Entitlement state store + the projected check read model | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Posture is a function of committed state + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-deterministic-posture-ent` + +Entitlement posture is a deterministic function of committed subscription state; every grant/revoke +is a consequence of a committed transition, evented + audited ([`../PRD.md`](../PRD.md) §6.9). + +#### Serve the decision, never enforce + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-serve-not-enforce-ent` + +Subscriptions serves the decision state (allow/quota/limit); **OSS enforces** (allow/block/degrade). +This gear never executes enforcement ([`../PRD.md`](../PRD.md) §6.9; SEAMS **SUB-E3**). + +### 2.2 Constraints + +#### Assignment source is the catalog grant set + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-grant-set-source-ent` + +Entitlements are assigned from the pricing-published grant set (incl. the per-phase map); this gear +authors no template — it resolves + materialises per subscription (SEAMS **SUB-P2**). + +#### Quota state is commercial state + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-quota-commercial-ent` + +Quota state is auditable commercial state, not an OSS side effect; crossings + exhaustion/restore +emit auditable events — never a silent overrun ([`../PRD.md`](../PRD.md) §6.9). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-ent` + +- **`Entitlement`** — a granted feature flag / usage quota / resource quota on a subscription, with effective dates and source grant-set reference. +- **`GrantSetAssignment`** — the resolved grant set (incl. the active phase's map) materialised per subscription at a transition. +- **`QuotaCounter`** — usage-vs-quota tracking (fed by the rating pipeline), soft/hard thresholds, exhaustion/restore state. +- **`CheckDecision`** — the read-surface value object: flag decision, quota remaining, limit state. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-entitlements-ent` + +- **`EntitlementAssigner`** — resolves the grant set (per-phase where phased) and materialises assignments at activation / phase boundary / committed change. +- **`IssueRevokeHandler`** — issues/revokes to match the posture on each resource-affecting transition; emits the producer events. +- **`QuotaTracker`** — folds rating-pipeline usage into `QuotaCounter`s; fires soft/hard + exhaustion/restore events. +- **`CheckReadSurface`** — serves the tenant-isolated `CheckDecision` at p95 < 100ms. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-entitlement-check-ent` + +The **point-of-use check** read contract (feature-flag decision, quota remaining, limit state) is +owned here — tenant-isolated, cache-friendly, p95 < 100ms; OSS calls it and enforces. The internal +admin `issueEntitlement`/`revokeEntitlement` paths are audit-mandatory. Wire mappings + the external +OSS contract are refined in [`09-consumer-contracts.md`](./09-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (issue/revoke at commit), +[`02-composition-versioning.md`](./02-composition-versioning.md) (active plan/phase @ `t`), and +[`06-trials.md`](./06-trials.md) (per-phase re-issue on conversion). Feeds +[`08-events-billing.md`](./08-events-billing.md) (entitlement events). + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Pricing | Grant-set templates incl. per-phase map (D-41) | SEAMS **SUB-P2** | +| Rating | Usage aggregates feeding quota counters | [`../PRD.md`](../PRD.md) §6.9 | +| OSS | Calls the check surface + enforces the decision | SEAMS **SUB-E3** | +| Billing / Rating | Prepaid-credit balance/drawdown (GA-gated) | SEAMS **SUB-B4** | + +### 3.6 Interactions and Sequences + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-flow-entitlement-check-ent` + +**Point-of-use check** (refines `cpt-cf-bss-subscriptions-seq-entitlement-check`): OSS reads the +`CheckReadSurface` for `(subscription, feature/quota)` → returns flag decision + quota remaining + +limit state at p95 < 100ms → OSS enforces (allow/block/degrade). Committed transitions update the +surface within the < 5s propagation baseline; quota crossings emit soft/hard + exhaustion/restore +events. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-entitlement-ent` + +Owned here: `entitlement` (state + effective dates + grant-set source), `quota_counter`, and the +projected `entitlement_check_read_model` (cache-first, tenant-isolated). Tenant-partitioned. Concrete +DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-ent` + +The check read path is served from the projected read model for the p95 < 100ms target with the +**bounded-staleness degraded mode** (SUB-D-10): on projection outage it serves the **last-known-good +decision up to the staleness budget** (default 60s — Product/OSS knob, [`../PRD.md`](../PRD.md) §15), +then fails closed. Assignment runs on the commit path +([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) §3.8). + +## 4. Additional Context + +### 4.1 Issue / Revoke from Transitions (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-issue-revoke-ent` + +- On a successful resource-affecting transition whose outcome requires grants/withdrawals (activate, suspend, resume, cancel, Policy-gated plan/add-on/quantity changes) the gear issues/revokes entitlements to match the new posture and emits `EntitlementIssued`/`EntitlementRevoked` ([`../PRD.md`](../PRD.md) §6.9 AC 12). +- Issue/revoke is part of the **same commit** as the transition (slice 01) — posture never lags committed state. +- **Freeze is a first-class state (2026-07-15 review fix):** `Entitlement.state ∈ {active, frozen, revoked}` — suspend **freezes** by default (restorable without re-materialisation; revoke only where product policy says so), resume **unfreezes**. `QuotaCounter`s **persist across suspend/resume and `collectionPaused`** — a mid-cycle suspension does not reset the cycle's usage; counters reset only at the §4.4 cycle boundary. + +### 4.2 Assignment from the Grant Set (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-assignment-ent` + +- On activation, phase boundary, and committed plan/add-on change, entitlements (flags, usage quotas, resource quotas) are assigned from the plan's **published grant set** — including the **per-phase map** where phased (pricing **D-41**) — with immediate or end-of-cycle effective dates aligned to the transition's `changeMode` ([`../PRD.md`](../PRD.md) §6.9; SEAMS **SUB-P2**). +- The catalog authors the templates; this gear resolves + materialises the assignment per subscription — it authors no template. + +### 4.3 Point-of-Use Check and Enforcement Split (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-check-split-ent` + +- The gear exposes a real-time check read contract (flag decision, quota remaining, limit state) at **p95 < 100ms**, tenant-isolated + cache-friendly; **OSS enforces** (allow/block/degrade) — Subscriptions serves the decision state, never enforcement ([`../PRD.md`](../PRD.md) §6.9; SEAMS **SUB-E3**). +- Entitlement updates propagate to the check surface within the §7.1 baseline (< 5s). +- **Failure semantics (SUB-D-10, 2026-07-15 review fix):** the normal-mode cache and the outage mode follow **one staleness rule** — a decision may be served up to the **staleness budget** old (default 60s; the < 5s propagation is the normal-mode bound inside it); beyond the budget the check **fails closed**. This replaces the earlier self-contradictory "cache-first but never stale-allow" posture; a binary fail-closed read path would turn one projection outage into a platform-wide access block. Transitions (the Policy gate) remain strictly fail-closed — the budget applies to the **read** surface only. Decision cacheability/TTL toward OSS is part of the slice-09 contract and MUST fit inside the same budget. +- **Flag vs quota-remaining split (2026-07-15 review fix):** the staleness budget above governs **feature-flag** decisions (grant present/absent — a value that only changes on a committed transition, so 60s-stale is safe and fail-open-within-budget is correct). It does **not** license serving a stale **`quota remaining` / hard-limit** decision as *allow*: quota is a fast-moving counter, and a stale-allow past a `hard` threshold is exactly the "silent overrun" §4.4 forbids. The quota dimension of `CheckDecision` therefore carries its own tighter freshness contract — it is served from the authoritative `QuotaCounter` fold, and when that fold is stale beyond the quota freshness bound the quota decision **fails closed to `blocking`** (deny) rather than serving last-known-good. The flag and quota dimensions of one `CheckDecision` can thus degrade independently. The absolute usage→counter end-to-end lag stays the open NFR (§4.4); this fix pins only the *degradation posture*, which is a design call, not a Product knob. +- **Open (§15):** mid-request behaviour at the exhaustion instant (graceful degradation vs hard block) is an OSS/Design decision — pin the check-state ↔ enforcement contract with OSS at Design; the budget default is a Product/OSS knob. + +### 4.4 Quota Soft/Hard Limits (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-quota-ent` + +- Usage is tracked against quotas (aggregates fed by the rating pipeline); a **soft limit** crossing emits an auditable warning + MAY route overage per plan policy; a **hard limit** flips the check state to **blocking** — never a silent overrun ([`../PRD.md`](../PRD.md) §6.9). The events are `EntitlementQuotaWarning` / `EntitlementQuotaExhausted` / `EntitlementQuotaRestored` (SUB-D-09, slice 08 registry). +- **Quantity-scaled quotas:** where the pricing grant set marks a quota **per-seat**, the materialised value is `grant × committed quantity @ t` (slice 03 `QuantityInterval`); a committed `updateQuantity` re-materialises the affected quotas at its boundary per `changeMode`. +- **Cycle reset:** counters reset at the billing-anchor cycle boundary — triggered by the recurring period cut (slice 08), not by a separate clock, so quota cycles and billing periods can never drift apart. +- **Propagation exposure:** the usage→check-state path (rating pipeline → `QuotaTracker` → surface) bounds how fast a hard limit takes effect; the end-to-end lag budget is an open NFR ([`../PRD.md`](../PRD.md) §15) — until set, the exposure is measured and reported by the §17.1 entitlement-sync reconciliation. +- Exhaustion + restore (new cycle, quota increase, plan change) emit auditable events. + +### 4.5 Prepaid Grant Hook (deferred) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-prepaid-hook-ent` + +- The prepaid-credit **definition** is pricing **D-43**; the **balance/drawdown** is Billing/Rating, **GA-gated** (SEAMS **SUB-B4**; [`../PRD.md`](../PRD.md) §2.2). Subscriptions supplies the subscription-side hook only (which subscription, which grant reference) — it neither defines nor draws down the wallet. +- Resolve the drawdown/tax placement jointly with pricing + Billing (pricing gap **G-4**) before prepaid GA; no launch dependency for the core lifecycle. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.9 (`fr-entitlement-issue-revoke`, `fr-entitlement-assignment`, `fr-entitlement-check-contract`, `fr-entitlement-quota-limits`), §6.9 AC 12, §7.1 (check latency), §15 (mid-request open), §2.2 (prepaid). +- **Seams**: **SUB-P2**, **SUB-E3**, **SUB-B4** — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (commit-time issue/revoke), [`02-composition-versioning.md`](./02-composition-versioning.md) (phase @ `t`), [`06-trials.md`](./06-trials.md) (per-phase re-issue), [`08-events-billing.md`](./08-events-billing.md) (entitlement events), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (OSS check contract). diff --git a/gears/bss/subscriptions/docs/design/06-trials.md b/gears/bss/subscriptions/docs/design/06-trials.md new file mode 100644 index 000000000..e772dd8fe --- /dev/null +++ b/gears/bss/subscriptions/docs/design/06-trials.md @@ -0,0 +1,243 @@ + + + +# DESIGN — Trial Runtime & Conversion (Slice 6) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-trials` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Trial as a Phase, Not a Status (normative)](#41-trial-as-a-phase-not-a-status-normative) + - [4.2 End-of-Trial Conversion (normative)](#42-end-of-trial-conversion-normative) + - [4.3 Early Conversion — convertTrial (normative)](#43-early-conversion--converttrial-normative) + - [4.4 Expiry Without Conversion (normative)](#44-expiry-without-conversion-normative) + - [4.5 Trial Extension (normative)](#45-trial-extension-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice runs the trial **on the phase machinery** — never a status fork. A trial is the leading +**plan phase**; the subscription occupies a manifest status (`draft` before first paid activation, +`active` under trial service) while trial state lives as attributes + `PlanLink`/snapshot pointers +([`../PRD.md`](../PRD.md) §6.1, §6.10). Conversion — at term via `convertsToPhaseId`, or early via +`convertTrial` — advances the phase boundary, an instant the rating gear consumes exactly like a +`changeEffectiveAt`; entitlements re-issue per the target phase's grant set with **no access gap**. + +Two seams meet here: **SUB-R4** (the phase boundary = change boundary, travelling on the shared +`(changeEffectiveAt, changeMode)` channel) and **SUB-P3** (the trial sellable definition is +Catalog-authored). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-trial-provisioning` / `cpt-cf-bss-subscriptions-fr-trial-commercial-pattern` | A trial is created from a Catalog-defined trial offer (trial plan/SKU, promotional PriceWindow, or leading trial phase); trial state is evaluated attributes + `PlanLink`/snapshot pointers; feature access follows the trial-phase grant set (§4.1). | +| `cpt-cf-bss-subscriptions-fr-trial-conversion` | At trial end, convert per `convertsToPhaseId`: advance the phase boundary, authorize payment where required, re-issue entitlements with continuity, emit the composition-changing event; idempotent (zero missed / zero double) (§4.2). | +| `cpt-cf-bss-subscriptions-fr-trial-early-conversion` | `convertTrial` is a first-class `TransitionRequest` advancing the boundary to `now` (the phase twin of `changePlan`), Policy-gated where resource-affecting, idempotent (§4.3). | +| `cpt-cf-bss-subscriptions-fr-trial-expiry` | An unconverted trial follows the configured end action via normal transitions (typically `cancel`); entitlements removed; optional win-back hook emitted (§4.4). | +| `cpt-cf-bss-subscriptions-fr-trial-extension` | An approval-gated operation moving the conversion date + trial-phase end consistently, with audit (§4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Conversion job | Idempotent conversion (zero missed / zero double); reconciliation §17.1 trial-conversion check | Reconciliation + fixtures | +| `cpt-cf-bss-subscriptions-nfr-lifecycle-latency` | `convertTrial` commit path | Synchronous commit class (p95 < 1s) | Load test | + +#### Key ADRs + +No slice-local ADR; the trial-as-phase modelling is governed by the closed-status-machine ADR (slice +01) and SEAMS **SUB-P3**/**SUB-R4**. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-trl` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Trial provisioning, conversion (term + early), expiry, extension | Rust module in the `subscriptions` gear | +| Domain | Trial phase state, `convertsToPhaseId` boundary, conversion + extension records | Rust; GTS + Rust domain structs | +| Infrastructure | Trial phase-state table; conversion job via the lease library | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Trial is a phase, not a status + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-trial-phase-trl` + +A trial is the leading plan phase on a manifest status; there is no `trial` status or edge. Trial +state is attributes + `PlanLink`/snapshot pointers ([`../PRD.md`](../PRD.md) §6.1; slice 01 closed +enum). + +#### Conversion is continuous + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-conversion-continuity-trl` + +Conversion re-issues entitlements per the target phase with **no access gap** and is **idempotent** — +zero missed, zero double conversions ([`../PRD.md`](../PRD.md) §6.10). + +### 2.2 Constraints + +#### Catalog owns the trial definition + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-catalog-trial-trl` + +The trial sellable definition (trial plan/SKU, promotional PriceWindow, leading phase) is +Catalog-authored; attribute-only trials are permitted only when Contract records the trial commercial +terms (SEAMS **SUB-P3**; [`../PRD.md`](../PRD.md) §6.1). + +#### Phase boundary rides the shared channel + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-phase-channel-trl` + +A conversion advances the phase boundary as a `(changeEffectiveAt, changeMode)` the rating gear +consumes like any change boundary — no second boundary vocabulary (SEAMS **SUB-R4**). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-trl` + +- **`TrialPhaseState`** — the active trial phase (`phase_id`), duration, `convertsToPhaseId`, evaluated trial attributes. +- **`ConversionRecord`** — the conversion event (term or early), boundary instant, payment-authorization outcome, entitlement re-issue reference; idempotency key. +- **`TrialExtension`** — an approval-gated move of the conversion date + trial-phase end (with `Approval`). + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-trials-trl` + +- **`TrialProvisioner`** — creates the trial from the Catalog offer on the phase machinery. +- **`ConversionEngine`** — the term (`convertsToPhaseId`) + early (`convertTrial`) conversion; advances the phase boundary, drives payment authorization, re-issues entitlements (slice 05). +- **`TrialExpiryHandler`** — the end action for an unconverted trial (typically `cancel`) + the win-back hook. +- **`TrialExtensionHandler`** — the approval-gated extension. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-trial-trl` + +`convertTrial` is a first-class operation (`TransitionRequest.type`, pending manifest alignment, slice +01 §4.6); trial provisioning + extension are operations with the standard idempotency/approval rules. +The conversion emits a composition-changing event consumed like `SubscriptionPlanChanged`. Wire +mappings + event fields are owned by [`08-events-billing.md`](./08-events-billing.md) / +[`09-consumer-contracts.md`](./09-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (`convertTrial` transition, +`Approval` for extension), [`02-composition-versioning.md`](./02-composition-versioning.md) (phase +intervals), and [`05-entitlements.md`](./05-entitlements.md) (per-phase grant re-issue). Payment +failure at conversion follows [`04-suspension-renewal-grace.md`](./04-suspension-renewal-grace.md)'s +grace ladder. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Pricing | Trial offer, `convertsToPhaseId`, per-phase grant set | SEAMS **SUB-P3**, **SUB-P2** | +| Payments | Conversion authorization (method on file) | SEAMS **SUB-F1** | +| Rating | Consumes the phase boundary as a change boundary | SEAMS **SUB-R4** | +| Notifications | Win-back hook delivery (trigger owned here) | SEAMS **SUB-F2** | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-trial-conversion-trl` + +**Conversion** (refines `cpt-cf-bss-subscriptions-seq-trial-conversion`): advance the phase boundary +(`convertsToPhaseId` at term, or `convertTrial` to `now`) → authorize payment where required (method +on file, no re-entry) → re-issue entitlements per the target phase's grant set with no access gap → +emit `SubscriptionTrialConverted` (the composition-changing conversion event, SUB-D-09); idempotent +(zero missed / zero double). + +**Payment failure at conversion (2026-07-15 review fix):** the boundary advance is **not rolled +back** — the phase boundary moves at its scheduled instant regardless (deterministic for rating), the +**target-phase entitlements are issued** (AC 16 continuity — the customer is on the paid phase, with +paid access), and the failed conversion charge enters the §6.5 grace ladder as its blocked collection; +grace failure exits to `suspended`/`cancelled` per the Contract ladder. Access continuity is never +traded for collection state — the grace ladder, not a grant rollback, is the pressure mechanism. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-trial-trl` + +Owned here: `trial_phase_state` (active phase, duration, `convertsToPhaseId`) and `conversion_record` +(idempotency-keyed). Trial attributes ride the aggregate; extension uses the Foundation `approval`. +Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-trl` + +The term-conversion job runs as a coordinated singleton via the lease library; `convertTrial` is a +synchronous control-plane transition ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) +§3.8). + +## 4. Additional Context + +### 4.1 Trial as a Phase, Not a Status (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-trial-phase-trl` + +- A trial subscription is created from a **Catalog-defined trial offer** (trial plan/SKU, promotional PriceWindow, or leading trial **phase**) with configurable duration; **no `trial` status** — evaluated attributes + `PlanLink`/snapshot pointers persist while the subscription occupies a manifest status ([`../PRD.md`](../PRD.md) §6.1, §6.10; SEAMS **SUB-P3**). +- Feature access during trial follows the **trial-phase grant set** (slice 05 assignment). + +### 4.2 End-of-Trial Conversion (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-conversion-trl` + +- At trial end, convert per the plan's phase schedule (`convertsToPhaseId`): advance the phase boundary, authorize payment where required (Payments; no re-entry where a method is on file), re-issue entitlements per the target phase with **continuity** (no access gap), emit the composition-changing event ([`../PRD.md`](../PRD.md) §6.10). +- Conversion is **idempotent** — zero missed / zero double; a payment failure follows the §6.5 grace ladder. +- **No method on file (2026-07-15 review fix):** where the target phase requires payment and **no method is on file**, the conversion is treated as an **immediate payment failure** — the boundary still advances and target-phase entitlements are issued (the §4.2/§3.6 continuity rule: access is never traded for collection state), and the missing-method failure enters the §6.5 grace ladder as its blocked collection, driving the pre-suspension notice path (a prompt to add a method) rather than a silent conversion. Grace start for a first-time trial→paid conversion is the conversion instant (there is no prior term), so the ladder is well-defined without a renewal term. + +### 4.3 Early Conversion — convertTrial (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-convert-trial-trl` + +- `convertTrial` ("skip the trial, start paying now") is a first-class `TransitionRequest` advancing the phase boundary to `now` — the **phase twin of `changePlan`**, the boundary instant consumed by rating like any `changeEffectiveAt`; re-issues entitlements per the target phase, emits `SubscriptionTrialConverted`; Policy-gated where resource-affecting, idempotent on `(subscriptionId, idempotencyKey)` ([`../PRD.md`](../PRD.md) §6.10; gap **G-2**, SEAMS **SUB-R4**). +- **From `draft`:** a `convertTrial` on a never-activated trial is an **activate** directly into the target phase (one committed transition, activation guards apply); the trial phase is skipped, not exited. +- This extends the manifest §4.3 `TransitionRequest.type` list — manifest alignment tracked in slice 01 §4.6 / [`../PRD.md`](../PRD.md) §15. An unconverted trial expiring while still `draft` exits via the `draft → cancelled` void edge (SUB-D-11). + +### 4.4 Expiry Without Conversion (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-expiry-trl` + +- An unconverted trial at expiry follows the configured end action via **normal transitions** (typically `cancel`; never a bespoke terminal status): entitlements removed, auditable events emitted, and an optional **win-back hook** published — campaign content + delivery = Notifications/Comms (out of scope §5.2) ([`../PRD.md`](../PRD.md) §6.10; SEAMS **SUB-F2**). + +### 4.5 Trial Extension (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-extension-trl` + +- A trial extension is the **approval-gated** `extendTrial` transition (SUB-D-08; Approval per the slice-01 high-risk pattern) moving the conversion date + trial-phase end consistently, with audit ([`../PRD.md`](../PRD.md) §6.10). +- **The moved boundary is announced to rating (2026-07-15 review fix):** the extension emits `SubscriptionTrialExtended` carrying the new phase-boundary instant on the shared `(changeEffectiveAt, changeMode)` channel (SEAMS **SUB-R4**) — the subscription-side phase state, not the snapshot-frozen phase schedule, is what rating resolves @ `t`, so the move needs no snapshot re-seal. +- **Open (§15):** the approval policy (automatic / manual / threshold-based) is a Product decision. Repeat-trial eligibility (serial re-trials) is an open Product/Pricing question ([`../PRD.md`](../PRD.md) §15) — the overlap rule blocks only concurrent duplicates. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.10 (`fr-trial-provisioning`, `fr-trial-conversion`, `fr-trial-early-conversion`, `fr-trial-expiry`, `fr-trial-extension`), §6.1 (`fr-trials-not-a-status`, `fr-trial-commercial-pattern`), §7.1 (NFRs), §15 (extension-policy open), §17.1 (trial-conversion reconciliation). +- **Seams**: **SUB-R4** (phase boundary), **SUB-P3** (trial offer); consumes **SUB-P2** (per-phase grant), **SUB-F1** (conversion auth) — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (`convertTrial`, approval), [`02-composition-versioning.md`](./02-composition-versioning.md) (phase intervals), [`04-suspension-renewal-grace.md`](./04-suspension-renewal-grace.md) (payment-failure grace), [`05-entitlements.md`](./05-entitlements.md) (per-phase re-issue), [`08-events-billing.md`](./08-events-billing.md) (conversion event). diff --git a/gears/bss/subscriptions/docs/design/07-tenancy-transfer.md b/gears/bss/subscriptions/docs/design/07-tenancy-transfer.md new file mode 100644 index 000000000..c5bac5e6f --- /dev/null +++ b/gears/bss/subscriptions/docs/design/07-tenancy-transfer.md @@ -0,0 +1,210 @@ + + + +# DESIGN — Multi-Tenant Ownership & Transfer (Slice 7) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-tenancy-transfer` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 The Three Tenant Axes (normative)](#41-the-three-tenant-axes-normative) + - [4.2 Delegation Proofs (normative)](#42-delegation-proofs-normative) + - [4.3 Hierarchy by Reference (normative)](#43-hierarchy-by-reference-normative) + - [4.4 Ownership Transfer Flow (normative)](#44-ownership-transfer-flow-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice owns the **multi-tenant backbone** every downstream consumer keys on: the three tenant +axes, delegation-proof enforcement for cross-tenant actions, hierarchy strictly **by reference** to +AMS, and the approval-gated **ownership transfer** flow. Subscriptions **references, never invents** +tenant topology — AMS is the identity SoR ([`../PRD.md`](../PRD.md) §6.6). Cross-tenant mutation +without a valid delegation proof is a critical-severity risk, so it is rejected fail-closed with the +proof reference audited. + +Transfer is a high-risk transition requiring an `Approval` (slice 01) and emitting the manifest +`OwnershipTransfer*` event trio. No cross-gear seam letter is load-bearing here beyond the AMS +identity boundary and the Policy gate the transfer runs through. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-tenant-axes` | The aggregate carries `resourceTenantId` (operational owner), `payerTenantId` (financial), `sellerTenantId` (channel) — the backbone every consumer keys on (§4.1). | +| `cpt-cf-bss-subscriptions-fr-delegation-proofs` | Cross-tenant admin actions carry an auditable delegation proof; an action without a valid proof is rejected + the audit records the explicit proof reference (§4.2). | +| `cpt-cf-bss-subscriptions-fr-hierarchy-reference` | Roll-ups follow account/OrgTier context from AMS; the gear references the AMS/BSS account binding, never forks topology (§4.3). | +| `cpt-cf-bss-subscriptions-fr-event-producers` (transfer) | Transfer is an `Approval`-gated transition emitting `OwnershipTransferRequested`/`Approved`/`Completed` (manifest §4.11) (§4.4). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-horizontal-partitioning` | Aggregate store + roll-up read models | Partition by the pinned `orderingTenantId` (SUB-D-06 — stable across transfers); bulk read models for account roll-ups; batch Policy where contractually safe | Load test | +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Transfer flow | Approval-gated; state-transition p95 < 500ms per step | Fixtures | + +#### Key ADRs + +No slice-local ADR; tenant identity is an AMS-SoR reference and transfer follows the manifest §4.11 +approval pattern. + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-tnt` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | Cross-tenant admin paths + transfer operations behind the gateway; delegation-proof binding | Rust, REST/OpenAPI | +| Application | Axis resolution, delegation-proof enforcement, transfer flow orchestration | Rust module in the `subscriptions` gear | +| Domain | Tenant-axis value objects, delegation-proof reference, transfer state + `Approval` | Rust; GTS + Rust domain structs | +| Infrastructure | Transfer-approval table; roll-up read models | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Reference identity, never invent it + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-reference-identity-tnt` + +Tenant identity + topology are AMS SoR; Subscriptions references the account binding and never forks +or invents topology ([`../PRD.md`](../PRD.md) §6.6). + +#### Cross-tenant mutation is proof-gated + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-proof-gated-tnt` + +Every cross-tenant admin action carries an auditable delegation proof or is rejected; the audit +always records the explicit proof reference ([`../PRD.md`](../PRD.md) §6.6 AC 10). + +### 2.2 Constraints + +#### Transfer is approval-gated + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-transfer-approval-tnt` + +Ownership transfer is a high-risk transition requiring an `Approval` (slice 01) and the +`OwnershipTransfer*` event trio; it runs through the Policy gate like any resource-affecting change +([`../PRD.md`](../PRD.md) §6.7, manifest §4.11). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-tnt` + +- **`TenantAxes`** — `resourceTenantId`, `payerTenantId`, `sellerTenantId` on the aggregate. +- **`DelegationProof`** — the auditable proof reference carried by a cross-tenant action. +- **`OwnershipTransfer`** — the transfer flow state (requested → approved → completed) + its `Approval` (slice 01) and the new tenant binding. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-tenancy-tnt` + +- **`AxisResolver`** — resolves + validates the three axes against AMS references. +- **`DelegationEnforcer`** — rejects a cross-tenant action lacking a valid proof; records the proof reference in audit. +- **`TransferOrchestrator`** — drives the approval-gated transfer flow + the `OwnershipTransfer*` events through the Foundation gate. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-transfer-tnt` + +Cross-tenant admin operations bind a delegation proof; `transfer` is an `Approval`-gated +`TransitionRequest`. The `OwnershipTransfer*` event contract + wire mappings are owned by +[`08-events-billing.md`](./08-events-billing.md) / [`09-consumer-contracts.md`](./09-consumer-contracts.md). + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (transfer `TransitionRequest`, +`Approval`, gate). Feeds [`08-events-billing.md`](./08-events-billing.md) (transfer events) and the +roll-up read models consumed by Analytics. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| AMS | Tenant identity + the three axes + delegation-proof backbone | [`../PRD.md`](../PRD.md) §6.6 | +| Policy Engine | Gate for the transfer transition | SEAMS **SUB-E1** | +| Analytics | Consumes roll-up read models + transfer facts | [`../PRD.md`](../PRD.md) §3.2 | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-transfer-tnt` + +**Ownership transfer**: `OwnershipTransferRequested` (with delegation proof) → `Approval` +(maker-checker) → `OwnershipTransferApproved` → Policy gate + rebind the tenant axes in one Foundation +commit → `OwnershipTransferCompleted`. A missing/invalid delegation proof rejects at request time with +the attempt audited. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-tenancy-tnt` + +Owned here: `ownership_transfer` (flow state + new binding) using the Foundation `approval`; tenant +axes + delegation-proof references ride the aggregate / transition record. Roll-up read models are +projections. Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-tnt` + +No slice-specific topology beyond the Foundation's; roll-up read models are projected off the commit +path ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) §3.8). + +## 4. Additional Context + +### 4.1 The Three Tenant Axes (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-axes-tnt` + +- The aggregate carries `resourceTenantId` (operational owner of the resources), `payerTenantId` (financial responsibility / consolidated billing), and `sellerTenantId` (channel/marketplace seller) — the multi-tenant backbone every downstream consumer keys on ([`../PRD.md`](../PRD.md) §6.6). +- The ordering/partition key uses the **pinned `orderingTenantId`** — stamped at creation (= `resourceTenantId` at creation) and **immutable** (SUB-D-06): a transfer rebinds the commercial axes, never the ordering key, so pre- and post-transfer events share one partition and no aggregate row set ever migrates partitions (slice 01 §4.2, SEAMS **SUB-R1**). + +### 4.2 Delegation Proofs (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-delegation-tnt` + +- A cross-tenant admin action MUST carry an **auditable delegation proof** (manifest §2.1.3); an action without a valid delegation is rejected, and the audit record includes the explicit proof reference ([`../PRD.md`](../PRD.md) §6.6 AC 10). + +### 4.3 Hierarchy by Reference (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-hierarchy-tnt` + +- Commercial roll-ups follow **account** + **OrgTier** context from AMS; Subscriptions **references** the AMS/BSS account binding and MUST NOT invent tenant topology — one identity SoR, no BSS fork ([`../PRD.md`](../PRD.md) §6.6). + +### 4.4 Ownership Transfer Flow (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-transfer-tnt` + +- Transfer is a high-risk transition: an `Approval` record is required (slice 01) and the flow emits `OwnershipTransferRequested`/`Approved`/`Completed` (manifest §4.11); it passes the Policy gate like any resource-affecting change ([`../PRD.md`](../PRD.md) §6.7). +- The axis rebind is a single Foundation commit **on the pinned partition** (SUB-D-06); `OwnershipTransferCompleted` carries **both the old and the new axes** so consumers (rating, Billing, the check surface) re-key their own projections — there is no stream barrier and no partition migration (AC 26). +- **Guards at commit (2026-07-15 review fix):** the delegation proof is validated at request time **and re-validated at commit** (approvals can take days; an expired/revoked proof aborts, audited); a `payerTenantId` rebind re-runs the **overlap check** against the new key (slice 03 §4.4) fail-closed; where resources move operational ownership, the transfer coordinates **OSS re-homing work orders** (Foundation async note) before the completing commit; the tenant-isolated **entitlement check surface re-keys** to the new tenant axes at commit. +- **Collection boundary:** the payer rebind defaults to **next-cycle** — the in-flight billing period stays with the old payer (the recurring fact for it is already keyed); an immediate rebind requires the Billing-side treatment of the mid-period split, which is an open Product/Billing question ([`../PRD.md`](../PRD.md) §15). Cross-currency/region transfers are rejected toward **cancel+new** (slice 02 §4.2, slice 03 §4.3). + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.6 (`fr-tenant-axes`, `fr-delegation-proofs`, `fr-hierarchy-reference`), §6.6 AC 10, §6.7 (`OwnershipTransfer*`), §7.1 (partitioning NFR). +- **Seams**: AMS identity boundary + **SUB-E1** (transfer gate); shares `resourceTenantId` with **SUB-R1** — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (transfer transition, approval, gate), [`08-events-billing.md`](./08-events-billing.md) (transfer events), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (event contract). diff --git a/gears/bss/subscriptions/docs/design/08-events-billing.md b/gears/bss/subscriptions/docs/design/08-events-billing.md new file mode 100644 index 000000000..c07eb391b --- /dev/null +++ b/gears/bss/subscriptions/docs/design/08-events-billing.md @@ -0,0 +1,250 @@ + + + +# DESIGN — Event Model & Billing Alignment (Slice 8) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-events-billing` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Producer Inventory and Payload Sufficiency (normative)](#41-producer-inventory-and-payload-sufficiency-normative) + - [4.2 Ordering (normative)](#42-ordering-normative) + - [4.3 Recurring Idempotency and No Retro-Edit (normative)](#43-recurring-idempotency-and-no-retro-edit-normative) + - [4.4 Traceability (normative)](#44-traceability-normative) + - [4.5 Dataset Separation and PriceOverride Consumption (normative)](#45-dataset-separation-and-priceoverride-consumption-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **outbound integration substrate**: the lifecycle **event producer inventory**, the +ordering guarantee, the recurring `BillableItem` idempotency + no-retro-edit invariants, and the +charge-to-catalog traceability tuple. The PRD fixes **sufficiency, not schema** — every lifecycle +event carries enough identity/tenancy/correlation/time context to route, deduplicate, and replay; +composition-changing events additionally carry enough snapshot-oriented commercial context for rating +and Billing to stay aligned and idempotent ([`../PRD.md`](../PRD.md) §6.7, §6.8). **This slice owns +the event naming registry and the field matrix** (§4.1, SUB-D-09); +[`09-consumer-contracts.md`](./09-consumer-contracts.md) owns only the wire mappings. + +Two seams meet here: **SUB-B1** (recurring idempotency `(subscriptionId, billing period)`, +posted-invoice immutability, `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef` +traceability) and the ordering half of **SUB-R1** (the pinned `(orderingTenantId, subscriptionId)` key shared +with rating); it also consumes **SUB-C5** (Contract `PriceOverride` windows). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-fr-event-producers` | The frozen producer set (`SubscriptionCreated`/`Activated`/`Suspended`/`Resumed`/`Cancelled`/`PlanChanged`, `BillableItemCreated(recurring)`, `EntitlementIssued`/`Revoked`, `OwnershipTransfer*`) emitted via the outbox (§4.1). | +| `cpt-cf-bss-subscriptions-fr-event-payload-completeness` | Sufficiency rule (not schema): identity/tenancy/correlation/time for route/dedup/replay; snapshot-oriented commercial context on composition-changing events (§4.1). | +| `cpt-cf-bss-subscriptions-fr-event-ordering` | Ordered within the pinned `(orderingTenantId, subscriptionId)` (SUB-D-06) — the key shared with rating partition ordering (§4.2). | +| `cpt-cf-bss-subscriptions-fr-recurring-idempotency` / `cpt-cf-bss-subscriptions-fr-no-retro-edit` | `BillableItem(kind=recurring)` idempotent per `(subscriptionId, billing period)`; posted lines never rewritten — corrections are new billable/adjustment artifacts (§4.3). | +| `cpt-cf-bss-subscriptions-fr-billing-traceability` / `cpt-cf-bss-subscriptions-fr-dataset-separation` | Every item traces to `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef`; subscription state ≠ posted invoice state (§4.4, §4.5). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Event outbox | Event delivery to consumers p95 < 30s; at-least-once + dedupable | Load test; baseline (workshop-pending) | +| `cpt-cf-bss-subscriptions-nfr-recurring-cut` | Recurring emission | Daily generation cut; zero duplicates via the idempotency key | Reconciliation §17.1 | + +#### Key ADRs + +No slice-local ADR; the ordering key + recurring idempotency are manifest invariants shared with +rating/Billing (SEAMS **SUB-R1**, **SUB-B1**). + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-evt` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Application | Producer emission + payload assembly; recurring idempotency keying; traceability stamping | Rust module in the `subscriptions` gear | +| Domain | Event envelopes, `BillableItem` recurring key, traceability tuple | Rust; GTS + Rust domain structs | +| Infrastructure | Transactional event outbox (committed with the transition) | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Sufficiency, not schema + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-sufficiency-evt` + +The PRD contract is payload **sufficiency** (route/dedup/replay; snapshot-oriented context on +composition changes); the field matrix + wire format are Design, owned with the consumer contracts +([`../PRD.md`](../PRD.md) §6.7). + +#### One ordering key + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-one-ordering-key-evt` + +Commit + emission preserve order within the pinned `(orderingTenantId, subscriptionId)` (SUB-D-06) so rating consumes +composition changes without reorder hazards ([`../PRD.md`](../PRD.md) §6.7; SEAMS **SUB-R1**). + +### 2.2 Constraints + +#### Recurring is idempotent; posted is immutable + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-recurring-idempotent-evt` + +At most one recurring `BillableItem` per `(subscriptionId, billing period)` even under bill-run +retries; posted invoice lines are never rewritten ([`../PRD.md`](../PRD.md) §6.8 AC 5). + +#### Outbox is committed with the transition + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-transactional-outbox-evt` + +Events are written to the outbox **in the same commit** as the state change (slice 01) — no event +without a committed transition, no committed transition without its events. + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-evt` + +- **`LifecycleEvent`** — the producer envelope (type, identity, tenancy, correlation, time, sequence); CloudEvents 1.0, tenant-scoped, minimal PII. +- **`RecurringBillableItem`** — the recurring handoff keyed `(subscriptionId, billing period)` with the traceability tuple + `pricingSnapshotRef`. +- **`TraceabilityTuple`** — `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef`. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-events-evt` + +- **`EventPublisher`** — assembles the sufficient payload + writes to the outbox in the transition commit. +- **`OrderingSequencer`** (Foundation) — enforces per-`(orderingTenantId, subscriptionId)` order on emission (the pinned tenant, SUB-D-06). +- **`RecurringEmitter`** — cuts the **money-free recurring period facts** idempotent per `(subscriptionId, billing period)` with the traceability tuple + pause/intent posture; rating prices them (SUB-D-07, §4.3). + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-events-evt` + +The producer inventory + the recurring `BillableItemCreated` handoff are the outbound contract; the +concrete event field matrix, CloudEvents extensions, and the Billing handoff payload are owned by +[`09-consumer-contracts.md`](./09-consumer-contracts.md). This slice fixes the producer set + the +sufficiency/ordering/idempotency/traceability rules. + +### 3.4 Internal Dependencies + +Depends on [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (transactional outbox, +`OrderingSequencer`); every capability slice (02–07) produces the events emitted here. + +### 3.5 External Dependencies + +| Dependency | What crosses the boundary | Contract | +|------------|---------------------------|----------| +| Rating | Consumes composition-changing events on the shared ordering key | SEAMS **SUB-R1** | +| Billing | Consumes recurring `BillableItem`s; posts immutable invoices | SEAMS **SUB-B1** | +| OSS / Policy / Analytics | Consume lifecycle facts + confirmations | [`../PRD.md`](../PRD.md) §6.7 | +| Contracts | `PriceOverride` windows consumed into composition/renewal | SEAMS **SUB-C5** | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-emit-evt` + +**Emit**: on a committed transition, `EventPublisher` writes the sufficient-payload event(s) to the +outbox in the same commit; `OrderingSequencer` assigns the per-aggregate sequence; the outbox delivers +at-least-once, dedupable, in order within the pinned `(orderingTenantId, subscriptionId)`. `RecurringEmitter` +cuts the recurring `BillableItem` idempotent per `(subscriptionId, billing period)` with the +traceability tuple + `pricingSnapshotRef`. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-events-evt` + +Uses the Foundation `event_outbox` (committed with the transition); the recurring idempotency key +`(subscriptionId, billing period)` is a unique index on the recurring handoff record. No separate +owned store. Concrete DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-evt` + +Outbox delivery + the recurring-generation cut run as coordinated singletons **per tenant partition** +(one lease per `orderingTenantId` shard, shard-parallel — the same sharding rule as slice 04's jobs), +with the same **intra-tenant sub-sharding by hash of `subscriptionId`** (slice 04 §3.8) so a single +large tenant's daily 00:00 cut is not serialised through one worker, so the cut and the p95 < 30s +delivery target are not funnelled through one global instance +([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) §3.8). + +## 4. Additional Context + +### 4.1 Producer Inventory and Payload Sufficiency (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-producers-evt` + +- Subscriptions emits (CloudEvents 1.0, tenant-scoped, minimal PII): `SubscriptionCreated`, `SubscriptionActivated`, `SubscriptionSuspended`, `SubscriptionResumed`, `SubscriptionCancelled`, `SubscriptionPlanChanged`, `BillableItemCreated(kind=recurring)`, `EntitlementIssued`, `EntitlementRevoked`, `OwnershipTransferRequested`/`Approved`/`Completed` ([`../PRD.md`](../PRD.md) §6.7). +- **Secondary producer set (SUB-D-09 — naming is normative here; this closes the PRD's "naming per Design" obligations):** + + | Event | Source / AC | + |-------|-------------| + | `SubscriptionIntentScheduled` / `SubscriptionIntentUnscheduled` | Scheduled intents, slice 01 §4.3 (AC 22); un-schedule voids a previously announced boundary (slice 03 event-once convention) | + | `SubscriptionQuantityChanged` | The composition-changing quantity event, slice 03 (AC 23; consumed like `SubscriptionPlanChanged`) | + | `SubscriptionRenewalSucceeded` / `SubscriptionRenewalFailed` | Renewal job outcome, slice 04 §4.3 (AC 7) | + | `SubscriptionGraceEntered` / `SubscriptionGraceExited` | Grace ladder, slice 04 §4.4 (AC 7; exit carries the resolution: renewed / suspended / cancelled) | + | `SubscriptionRenewalNoticeDue` | Notice trigger, slice 04 §4.5 (AC 19; delivery = Notifications) | + | `SubscriptionCollectionPaused` / `SubscriptionCollectionResumed` | Pause window, slice 04 §4.2 (AC 24) | + | `SubscriptionTrialConverted` / `SubscriptionTrialExtended` / `SubscriptionTrialExpired` | Trials, slice 06 (AC 16–18; `TrialExpired` doubles as the win-back hook; `TrialExtended` carries the moved boundary on the shared channel) | + | `SubscriptionAcceptanceConfirmed` | `confirmAcceptance`, slice 01 §4.4 (AC 25) | + | `EntitlementQuotaWarning` / `EntitlementQuotaExhausted` / `EntitlementQuotaRestored` | Quota crossings, slice 05 §4.4 (AC 14) | + +- **Sufficiency, not schema**: every lifecycle event carries enough identity/tenancy/correlation/time for route/dedup/replay **without** an undocumented side channel; composition-changing events carry enough snapshot-oriented commercial context that rating + Billing stay aligned on the effective offer and process idempotently (AC 11). The **field matrix is owned by this slice together with §4.1's registry** (per-event required-context groups: identity, tenancy incl. the pinned `orderingTenantId`, correlation, time, commercial snapshot context for composition changes); slice 09 owns only the wire mappings (SUB-D-09 closes the earlier circular deferral). + +### 4.2 Ordering (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-ordering-evt` + +- Order MUST be preserved within `(tenantId, aggregateId)` with `aggregateId = subscriptionId`; `tenantId` = the **pinned `orderingTenantId`** (= `resourceTenantId` at creation, immutable across transfers — SUB-D-06, AC 26) so subscription command ordering + downstream rating partition ordering share one **stable** key ([`../PRD.md`](../PRD.md) §6.7 AC 3; SEAMS **SUB-R1**). + +### 4.3 Recurring Idempotency and No Retro-Edit (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-recurring-evt` + +- `BillableItem(kind=recurring)` MUST be idempotent on `(subscriptionId, billing period)` — at most one recurring item per key even under bill-run retries ([`../PRD.md`](../PRD.md) §6.8 AC 5). +- **What the emitter cuts is the money-free period fact (SUB-D-07):** period identity from the billing anchor, the traceability tuple, `pricingSnapshotRef`, and the pause/intent posture — **no monetary column** (the Foundation store has none). The **rating gear prices** the recurring component from the frozen snapshot and the priced line **inherits the fact's key** before Billing posts (AC 27; SEAMS **SUB-R6**). This removes the double-producer collision with rating's recurring lines. +- **Pause marker:** during a `collectionPaused` window the fact is still emitted, marked, so Billing owns the suppress-vs-defer treatment (SUB-D-03/12, AC 24 — "not posted" is Billing's act, emission is ours). +- **Period-key stability:** the period identity is the anchor-derived canonical id frozen at emission; a cycle-length change (e.g. monthly→annual at a boundary) starts a **new period sequence** at that boundary — no retroactive re-keying of already-cut facts. +- **Cut-vs-intent race:** the daily cut reads the pending-intent set as of its run; an `unschedule` committed after the cut suppressed a period re-triggers a targeted re-cut via `SubscriptionIntentUnscheduled` (idempotent on the same key), with the §17.1 charge-coverage reconciliation as the backstop. +- Posted invoice lines MUST NOT be rewritten; subscription corrections emit **new** billable or adjustment paths ([`../PRD.md`](../PRD.md) §6.8; SEAMS **SUB-B1**). + +### 4.4 Traceability (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-traceability-evt` + +- Items MUST trace to `subscriptionId`, `skuId`/`planId`/`priceId`, and `pricingSnapshotRef` (manifest §4.4 itemization) — the charge-to-catalog lineage partners + auditors reconcile against ([`../PRD.md`](../PRD.md) §6.8). + +### 4.5 Dataset Separation and PriceOverride Consumption (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-dataset-priceoverride-evt` + +- Subscription state ≠ invoice posted state; late usage adjustments remain a Rating→Billing concern; this slice states **Billing invariants** for coordinated artifacts, not client control-plane operations (REST paths/methods/errors are Design) ([`../PRD.md`](../PRD.md) §6.8). +- Contract `PriceOverride` windows are consumed into composition/renewal via events/read models; in rating these are the step-5 contract overlay — Subscriptions references the binding, never evaluates the override (SEAMS **SUB-C5**). + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §6.7 (`fr-event-producers`, `fr-event-payload-completeness`, `fr-event-consumers`, `fr-event-ordering`), §6.8 (`fr-recurring-idempotency`, `fr-no-retro-edit`, `fr-billing-traceability`, `fr-dataset-separation`), AC 3/5/11, §7.1 (delivery NFR). +- **Seams**: **SUB-B1** (recurring/immutability/traceability), **SUB-R1** (ordering), **SUB-C5** (PriceOverride) — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (outbox, sequencer), capability slices 02–07 (event sources), [`09-consumer-contracts.md`](./09-consumer-contracts.md) (payload + Billing handoff contract). diff --git a/gears/bss/subscriptions/docs/design/09-consumer-contracts.md b/gears/bss/subscriptions/docs/design/09-consumer-contracts.md new file mode 100644 index 000000000..a4f496384 --- /dev/null +++ b/gears/bss/subscriptions/docs/design/09-consumer-contracts.md @@ -0,0 +1,248 @@ + + + +# DESIGN — Consumer & Integration Contracts (Slice 9) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-design-consumer-contracts` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles and Constraints](#2-principles-and-constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions and Sequences](#36-interactions-and-sequences) + - [3.7 Database Schemas and Tables](#37-database-schemas-and-tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Control-Plane Operations (normative)](#41-control-plane-operations-normative) + - [4.2 Rating Read-Model Contract (normative)](#42-rating-read-model-contract-normative) + - [4.3 Billing Handoff Contract (normative)](#43-billing-handoff-contract-normative) + - [4.4 Contracts Input Contract (normative)](#44-contracts-input-contract-normative) + - [4.5 Policy and OSS Contracts (normative)](#45-policy-and-oss-contracts-normative) + - [4.6 Payments and Notifications Contracts (normative)](#46-payments-and-notifications-contracts-normative) + - [4.7 Registry Overlap-Key Contract (normative)](#47-registry-overlap-key-contract-normative) +- [5. Traceability](#5-traceability) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +This slice is the **boundary surface**: the control-plane operations clients invoke and the external +integration contracts every neighbour consumes or supplies. It **assembles** the contracts the +capability slices produce — it introduces no new lifecycle policy. The PRD's §6 content boundary +holds: business operations + protocol/format intent live here; concrete REST paths, methods, +idempotency/ETag header bindings, OpenAPI, event field matrices, and error taxonomies are the Design +detail this slice frames ([`../PRD.md`](../PRD.md) §9). + +Nearly every seam surfaces at this boundary: **SUB-R1** (rating read-model), **SUB-B1** (Billing +handoff), **SUB-C1/C5** (Contracts input + `PriceOverride`), **SUB-E1/E2/E3** (Policy gate, OSS +provisioning, entitlement check), **SUB-F1/F2** (Payments signals, Notifications triggers), and +**SUB-G1** (the registry overlap key). + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|-------------|-----------------| +| `cpt-cf-bss-subscriptions-interface-control-plane` | The business-operations contract (create/get/list/activate/suspend/resume/cancel/changePlan/add-on/entitlement) with idempotency + optimistic-concurrency requirements; REST mapping is Design (§4.1). | +| `cpt-cf-bss-subscriptions-contract-rating-read-model` | Composition read models (effective `PlanLink`/`AddOn`, `PlanTier` @ `t`, active phase @ `t`, `(changeEffectiveAt, changeMode)`) on the shared ordering key (§4.2). | +| `cpt-cf-bss-subscriptions-contract-billing-handoff` | `BillableItemCreated(recurring)` idempotent per `(subscriptionId, billing period)` + traceability tuple; proration only as new/adjusting artifacts (§4.3). | +| `cpt-cf-bss-subscriptions-contract-contracts-input` | Signed terms, `Renewal`, grace/regional templates, `PriceOverride` via events + read models; evaluated fields stored at evaluation time (§4.4). | +| `cpt-cf-bss-subscriptions-contract-policy-gate` / `cpt-cf-bss-subscriptions-contract-oss-provisioning` | Pre-commit allow/deny + `reasonCodes`, fail-closed; provision/deprovision/pause confirmed by events (§4.5). | +| `cpt-cf-bss-subscriptions-contract-payments-signals` | Payment pre-check + retry-exhaustion consumed by the grace ladder; PSP webhooks + dunning payloads are Design (§4.6). | + +#### NFR Allocation + +| NFR theme | Allocated To | Design Response | Verification / Status | +|-----------|--------------|-----------------|-----------------------| +| `cpt-cf-bss-subscriptions-nfr-operational-baselines` | Read/handoff contracts | Subscription query p95 < 200ms; event delivery p95 < 30s; bulk read models for roll-ups | Load test; baseline (workshop-pending) | +| `cpt-cf-bss-subscriptions-nfr-entitlement-check-latency` | Check contract (slice 05) | p95 < 100ms surfaced through this boundary | Load test before GA | + +#### Key ADRs + +No slice-local ADR; the contracts realise the seams frozen in [`../SEAMS.md`](../SEAMS.md). + +### 1.3 Architecture Layers + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-tech-stack-con` + +| Layer | Responsibility | Technology | +|-------|----------------|------------| +| Presentation | REST control-plane + read surfaces behind the inbound gateway; RFC 9457 problems; OAuth 2.0; idempotency + ETag | Rust, REST/OpenAPI, inbound API gateway | +| Application | Contract assembly over the capability slices; event/read-model projection surfaces | Rust module in the `subscriptions` gear | +| Domain | The integration contract value objects (handoff, read-model, gate, signals) | Rust; GTS + Rust domain structs | +| Infrastructure | Read-model projections + the outbox (Foundation) | PostgreSQL, SecureORM | + +## 2. Principles and Constraints + +### 2.1 Design Principles + +#### Assemble, never re-author + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-assemble-con` + +This slice assembles the contracts the capability slices produce; it introduces no new lifecycle +policy — the boundary is a projection of committed state + emitted events ([`../PRD.md`](../PRD.md) §9). + +#### Protocol intent here, wire detail in Design + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-principle-intent-not-wire-con` + +The slice fixes protocol/format **intent** (what crosses, idempotency/ordering guarantees); REST +paths, headers, OpenAPI, and error taxonomies are the Design detail it frames ([`../PRD.md`](../PRD.md) +§6 content boundary, §9.1). + +### 2.2 Constraints + +#### Fail-closed at every gated boundary + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-constraint-fail-closed-boundary-con` + +The Policy gate fails closed unconditionally (no allow on deny/unavailability); the entitlement +check fails closed **beyond the SUB-D-10 staleness budget** (last-known-good decisions are served +inside it — slice 05 §4.3); the Billing handoff never mutates posted state +([`../PRD.md`](../PRD.md) §9.2; SEAMS **SUB-E1**, **SUB-B1**). + +## 3. Technical Architecture + +### 3.1 Domain Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-domain-model-con` + +The boundary value objects: `ControlPlaneOperation`, `CompositionReadModel` (rating input), +`RecurringHandoff` (Billing), `ContractsInput` (terms + `PriceOverride`), `PolicyDecision`, +`OssWorkOrder`, `PaymentsSignal`, `CheckDecision` (slice 05). Each projects committed state / emitted +events; none owns lifecycle policy. + +### 3.2 Component Model + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-component-consumer-contracts-con` + +- **`ControlPlaneApi`** — the REST business-operations surface (idempotency + ETag). +- **`ReadModelProjector`** — the rating composition read model + roll-up projections. +- **`BillingHandoff`** — the recurring `BillableItem` + traceability handoff. +- **`ContractsAdapter`** / **`PolicyGateAdapter`** / **`OssAdapter`** / **`PaymentsAdapter`** — the inbound/outbound neighbour adapters. + +### 3.3 API Contracts + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-interface-boundary-con` + +Owns the concrete shape of `cpt-cf-bss-subscriptions-interface-control-plane` (§9.1) and the six §9.2 +external contracts (`-contract-billing-handoff`, `-contract-rating-read-model`, +`-contract-contracts-input`, `-contract-policy-gate`, `-contract-oss-provisioning`, +`-contract-payments-signals`), plus the point-of-use check contract surfaced from +[`05-entitlements.md`](./05-entitlements.md). Concrete REST/OpenAPI/error taxonomy is Design. + +### 3.4 Internal Dependencies + +Depends on every slice: [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) (transition + +outbox), [`02-composition-versioning.md`](./02-composition-versioning.md) (read model), +[`03-plan-changes.md`](./03-plan-changes.md) (change events), [`04-suspension-renewal-grace.md`](./04-suspension-renewal-grace.md) +(Payments/Billing), [`05-entitlements.md`](./05-entitlements.md) (check contract), +[`06-trials.md`](./06-trials.md) (conversion event), [`07-tenancy-transfer.md`](./07-tenancy-transfer.md) +(transfer events), [`08-events-billing.md`](./08-events-billing.md) (producer inventory + handoff). + +### 3.5 External Dependencies + +| Dependency | Direction | Contract | +|------------|-----------|----------| +| Rating | out (read model + events) | SEAMS **SUB-R1** | +| Billing | out (recurring handoff) | SEAMS **SUB-B1** | +| Contracts | in (terms, `PriceOverride`) | SEAMS **SUB-C1**, **SUB-C5** | +| Policy Engine | out (gate) / in (decision) | SEAMS **SUB-E1** | +| OSS | out (work orders) / in (check calls, confirmations) | SEAMS **SUB-E2**, **SUB-E3** | +| Payments | in (signals) | SEAMS **SUB-F1** | +| Registry | in (overlap key, published SKU) | SEAMS **SUB-G1**, **SUB-G2** | + +### 3.6 Interactions and Sequences + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-flow-boundary-con` + +The boundary flows are the per-contract sequences owned by the source slices (transition commit — +slice 01; change boundary — slice 03; renewal/grace — slice 04; check — slice 05; emit — slice 08). +This slice composes them into the external surface; no new sequence beyond assembly. + +### 3.7 Database Schemas and Tables + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-storage-boundary-con` + +No owned store; the boundary reads the projected read models + the outbox owned by the Foundation + +capability slices. Concrete projection DDL is Design. + +### 3.8 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-cf-bss-subscriptions-deployment-con` + +The control-plane + read surfaces run behind the inbound gateway; read models are projected off the +commit path. **Failure postures are per surface (2026-07-15 review fix):** only the entitlement +**check** surface carries the bounded-staleness/fail-closed posture (SUB-D-10, slice 05); the +composition read model and roll-ups serve committed state under normal eventual consistency +([`../PRD.md`](../PRD.md) §14) — a projection outage there degrades freshness, it does not block +rating/Billing reads of already-served state ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) +§3.8). + +## 4. Additional Context + +### 4.1 Control-Plane Operations (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-control-plane-con` + +- The business operations (create/get/list; activate/suspend/resume/cancel incl. void-from-draft; changePlan; add-on; `updateQuantity`; `convertTrial`/`extendTrial`; manual `renew`; `unschedule`; `pauseCollection`/`resumeCollection`; `confirmAcceptance`; `transfer`; entitlement issue/revoke — the full PRD §9.1 table incl. the SUB-D-08 set) carry idempotency keys + optimistic concurrency on `version`; resources are `Subscription`, `Entitlement`, `AddOn`, `PlanLink`, `TransitionRequest`, `Approval` ([`../PRD.md`](../PRD.md) §9.1). REST paths/methods/header bindings/OpenAPI are Design. + +### 4.2 Rating Read-Model Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-rating-contract-con` + +- Composition read models expose effective `PlanLink`/`AddOn` intervals, `PlanTier` @ `t`, active plan phase @ `t`, the plan-change `(changeEffectiveAt, changeMode)`, the **committed seat quantity @ `t`** (effective-dated — pricing `quantitySource = subscription_seat_count`), the **`priceEligibility` inputs** (`activatedAt`, bound `cohort`), and the per-sale **`brandId`** context (source discrepancy with rating open — SEAMS **SUB-R5**); ordering is shared on the pinned `(orderingTenantId, subscriptionId)` (SUB-D-06) — rating PRD §9.2 "Subscriptions input contract" is the counterpart, and the two field lists MUST stay mirror-aligned (SEAMS **SUB-R1**, downgraded to Joint until reconciled) ([`../PRD.md`](../PRD.md) §9.2). + +### 4.3 Billing Handoff Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-billing-contract-con` + +- `BillableItemCreated(kind=recurring)` — the **money-free period fact** idempotent per `(subscriptionId, billing period)`, carrying `{subscriptionId, skuId, planId, priceId}` + `pricingSnapshotRef` + the pause/intent posture; the rating gear prices it and the priced line inherits the key before Billing posts (SUB-D-07; SEAMS **SUB-R6**); proration materialises only as new billable/adjusting artifacts; posted invoices immutable ([`../PRD.md`](../PRD.md) §9.2, §6.8; SEAMS **SUB-B1**). Billing additionally exposes the **`billedThroughAt`** watermark this gear's backdating guard consumes (SEAMS **SUB-B6**). + +### 4.4 Contracts Input Contract (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-contracts-contract-con` + +- Signed terms, `Renewal` (`autoRenew`, term windows), grace ladder / regional-template values, `PriceOverride` windows — consumed via events (`ContractSigned`, `ContractRenewed`, …) + read models; Subscriptions stores **evaluated fields** at renewal-evaluation time ([`../PRD.md`](../PRD.md) §9.2, §6.5; SEAMS **SUB-C1**, **SUB-C5**). +- **Risk:** the upstream Contracts PRD does not yet author the grace/regional-template SoR — until it does, the platform defaults govern (slice 04 §4.4). + +### 4.5 Policy and OSS Contracts (normative) + +- [ ] `p1` - **ID**: `cpt-cf-bss-subscriptions-normative-policy-oss-contract-con` + +- Policy: pre-commit allow/deny + `reasonCodes` for every resource-affecting transition; fail-closed on deny or unavailability; post-change confirmations per integration Design ([`../PRD.md`](../PRD.md) §9.2; SEAMS **SUB-E1**). +- OSS: provision/deprovision/pause work orders confirmed by events; entitlement issue/revoke aligned to committed transitions; BSS never mutates OSS topology directly ([`../PRD.md`](../PRD.md) §9.2; SEAMS **SUB-E2**). The point-of-use check contract (slice 05, p95 < 100ms) is OSS's enforcement input (**SUB-E3**). + +### 4.6 Payments and Notifications Contracts (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-payments-notif-contract-con` + +- Payments: pre-check outcomes + retry-exhaustion declarations consumed by the renewal/grace ladder; PSP webhooks + dunning handoff payloads are Design scope ([`../PRD.md`](../PRD.md) §9.2; SEAMS **SUB-F1**). +- Notifications: notice + win-back **triggers** are owned here; **delivery** is Notifications/Comms (SEAMS **SUB-F2**). + +### 4.7 Registry Overlap-Key Contract (normative) + +- [ ] `p2` - **ID**: `cpt-cf-bss-subscriptions-normative-registry-contract-con` + +- `catalogSubscriptionProductKey` (the `overlapScopeKey` half) is registry-owned; Design binds the stored field to a published SKU/product key; published `skuId`/`PlanTier`/`CatalogVersion` are read-only inputs ([`../PRD.md`](../PRD.md) §6.3; SEAMS **SUB-G1**, **SUB-G2**). Engage upstream PR #4177 before the key shape is frozen. + +## 5. Traceability + +- **PRD**: [`../PRD.md`](../PRD.md) §9.1 (`interface-control-plane`), §9.2 (`contract-billing-handoff`, `contract-rating-read-model`, `contract-contracts-input`, `contract-policy-gate`, `contract-oss-provisioning`, `contract-payments-signals`), §6 content boundary, §7.1 (NFRs), §16 (Contracts-grace risk). +- **Seams**: **SUB-R1**, **SUB-B1**, **SUB-C1**, **SUB-C5**, **SUB-E1**, **SUB-E2**, **SUB-E3**, **SUB-F1**, **SUB-F2**, **SUB-G1**, **SUB-G2** — [`../SEAMS.md`](../SEAMS.md). +- **Slices**: all — this is the assembled boundary surface over [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) … [`08-events-billing.md`](./08-events-billing.md). diff --git a/gears/bss/subscriptions/docs/design/README.md b/gears/bss/subscriptions/docs/design/README.md new file mode 100644 index 000000000..5c7d8b960 --- /dev/null +++ b/gears/bss/subscriptions/docs/design/README.md @@ -0,0 +1,58 @@ + + + +# Subscriptions — Design Set + + + +- [Slice documents](#slice-documents) +- [Slice map (PRD §6 ↔ slice)](#slice-map-prd-6--slice) + + + +This folder holds the **subscriptions gear's** technical design as a **set of slice designs**: a +shared **Lifecycle Foundation** ([`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md)) — the +subscription aggregate, the manifest-closed status machine, the `TransitionRequest` envelope with +idempotency + pinned `(orderingTenantId, subscriptionId)` ordering (SUB-D-06), the Policy/OSS fail-closed gate, +versioning, and the audit store — plus per-capability slices decomposed along the PRD §6 subsections. +Every capability slice requests transitions **through** the Foundation under those invariants; none +re-implements the commit/idempotency/gate mechanics. + +The gear is not an authoring System of Record: the pricing gear owns the catalog (`Plan`/`Price`/ +`PriceWindow`/`PriceOverlay`/`CatalogVersion`), the rating gear owns tariff evaluation + proration +math, Billing owns posting. The cross-gear contract is frozen in [`../SEAMS.md`](../SEAMS.md); this +design implements the subscriptions side of every listed seam. + +**The canonical index — architecture overview, slice map, dependency order, cross-cutting +normatives, the ADR index, and traceability — is [`../DESIGN.md`](../DESIGN.md).** Requirements +(WHAT/WHY) live in [`../PRD.md`](../PRD.md); the autonomous decisions in +[`../DECISIONS.md`](../DECISIONS.md); the cross-gear seam register in [`../SEAMS.md`](../SEAMS.md). + +## Slice documents + +- [`01-foundation-lifecycle.md`](./01-foundation-lifecycle.md) — **Lifecycle Foundation** — The shared substrate every capability slice runs *through*: the `Subscription` aggregate, the closed manifest status machine (`draft`→`active`↔`suspended`→`cancelled`→`archived`) with guards + terminality, the `TransitionRequest` envelope (idempotency on `(subscriptionId, idempotencyKey)`, ordering on the pinned `(orderingTenantId, subscriptionId)`, SUB-D-06), scheduled pending intents, the three activation instants, the Policy/OSS fail-closed gate, versioning, and the audit store. (PRD §6.1) +- [`02-composition-versioning.md`](./02-composition-versioning.md) — **Composition & Versioning** — Effective-dated `PlanLink`/`AddOn` intervals, monotonic `version`, snapshot discipline (the Subscriptions-written `(currency, region)` `pricingSnapshotRef` segment), `PlanTier` derivability @ `t`, per-sale brand attribution. (PRD §6.2) +- [`03-plan-changes.md`](./03-plan-changes.md) — **Plan & Quantity Changes** — The change boundary/mode + up/down asymmetry, `updateQuantity` seat provenance, ramp execution of scheduled intents, overlap-cardinality detection on the `overlapScopeKey`, and backdated-change guards against posted invoices. (PRD §6.3) +- [`04-suspension-renewal-grace.md`](./04-suspension-renewal-grace.md) — **Suspension, Renewal & Grace** — Suspend/resume semantics, the `collectionPaused` billing-only posture, the Contract-driven renewal job (auto/manual), notices + opt-out, and the failed-renewal grace ladder with evaluated fields. (PRD §6.4, §6.5) +- [`05-entitlements.md`](./05-entitlements.md) — **Entitlement Lifecycle** — Issue/revoke from transitions, assignment from the pricing published grant set (incl. the per-phase map), the point-of-use check decision state (p95 < 100ms, OSS enforces), and quota soft/hard limits. (PRD §6.9) +- [`06-trials.md`](./06-trials.md) — **Trial Runtime & Conversion** — Trial provisioning on the phase machinery, end-of-trial conversion (`convertsToPhaseId`), early `convertTrial`, expiry without conversion, and approval-gated extension. (PRD §6.10) +- [`07-tenancy-transfer.md`](./07-tenancy-transfer.md) — **Multi-Tenant Ownership & Transfer** — The three tenant axes referenced from AMS, delegation-proof enforcement, hierarchy by reference, and the approval-gated ownership-transfer flow. (PRD §6.6) +- [`08-events-billing.md`](./08-events-billing.md) — **Event Model & Billing Alignment** — The producer inventory + payload-sufficiency rules, ordering, recurring `BillableItem` idempotency per `(subscriptionId, billing period)`, charge-to-catalog traceability, and the event outbox. (PRD §6.7, §6.8) +- [`09-consumer-contracts.md`](./09-consumer-contracts.md) — **Consumer & Integration Contracts** — The integration surface: the Billing handoff, the rating read-model, the Contracts input, the Policy gate, OSS provisioning, and Payments signals. (PRD §9) + +## Slice map (PRD §6 ↔ slice) + +| Doc | PRD §6 | Seams owned | +|-----|--------|-------------| +| `01-foundation-lifecycle` | §6.1 | SUB-E1, SUB-C4, SUB-N1 | +| `02-composition-versioning` | §6.2 | SUB-R2, SUB-G2 | +| `03-plan-changes` | §6.3 | SUB-R1, SUB-R3, SUB-P1, SUB-C2, SUB-G1 | +| `04-suspension-renewal-grace` | §6.4, §6.5 | SUB-B2, SUB-C1, SUB-F1, SUB-B5, SUB-E2 | +| `05-entitlements` | §6.9 | SUB-P2, SUB-E3, SUB-B4 | +| `06-trials` | §6.10 | SUB-R4, SUB-P3 | +| `07-tenancy-transfer` | §6.6 | (AMS delegation; transfer approvals) | +| `08-events-billing` | §6.7, §6.8 | SUB-B1, SUB-R1 (ordering), SUB-C5 | +| `09-consumer-contracts` | §9 | SUB-R1, SUB-B1, SUB-C1, SUB-E1/E3, SUB-F1, SUB-G1 | + +The numeric prefix follows the PRD §6 decomposition and the dependency order in +[`../DESIGN.md`](../DESIGN.md) §1.3. diff --git a/gears/system/usage-collector/docs/PRD-phase2-usage-event-feed.md b/gears/system/usage-collector/docs/PRD-phase2-usage-event-feed.md new file mode 100644 index 000000000..4d759c498 --- /dev/null +++ b/gears/system/usage-collector/docs/PRD-phase2-usage-event-feed.md @@ -0,0 +1,455 @@ +# PRD — Usage Collector Phase 2: Usage Event Feed & Ingestion Watermarks + +> **Status**: proposal (2026-07-16). Adoption is decided **in this repo** (upstream-detach +> decision 2026-07-15/16: this branch is the canonical home; the collector's evolution for our +> platform lives here) — pending implementation scheduling. +> **Relationship to [PRD.md](./PRD.md)**: a self-contained phase-2 increment. The v1 PRD remains +> authoritative for every phase-1 surface; nothing here modifies a v1 requirement. Every phase-2 +> capability is **additive within REST v1 / SDK v1** under +> `cpt-cf-usage-collector-adr-contract-stability` (ADR-0006) and executes reserved hooks the v1 +> documents already name (DESIGN §4 deferral table: "Push / subscribe surface for downstream +> readers", "Rate limiting & watermarks"; PRD §4.2: watermark metadata "covered in a later +> phase"). On adoption, the sections below fold into the corresponding PRD.md sections per the +> one-PRD-per-gear convention. + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Actors](#2-actors) + - [2.1 New System Actor](#21-new-system-actor) + - [2.2 Actor Permissions (delta)](#22-actor-permissions-delta) +- [3. Operational Concept & Environment](#3-operational-concept--environment) +- [4. Scope](#4-scope) + - [4.1 In Scope](#41-in-scope) + - [4.2 Out of Scope](#42-out-of-scope) +- [5. Functional Requirements](#5-functional-requirements) + - [5.1 Usage Event Feed](#51-usage-event-feed) + - [5.2 Ingestion Watermarks](#52-ingestion-watermarks) +- [6. Non-Functional Requirements](#6-non-functional-requirements) +- [7. Public Library Interfaces](#7-public-library-interfaces) + - [7.1 Surface Additions](#71-surface-additions) + - [7.2 Usage Event Feed Contract](#72-usage-event-feed-contract) +- [8. Use Cases](#8-use-cases) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Dependencies](#10-dependencies) +- [11. Assumptions](#11-assumptions) +- [12. Risks](#12-risks) +- [13. Open Questions](#13-open-questions) +- [14. Traceability](#14-traceability) + + + +## 1. Overview + +### 1.1 Purpose + +Phase 2 adds the **outbound half** of the metering substrate: a durable, resumable, per-tenant- +ordered **Usage Event Feed** of accepted entries (usage records, compensation entries, status +transitions) plus per-`(tenant, UsageType)` **ingestion watermarks**. Phase 1 deliberately shipped +pull-only; phase 2 executes the additive push hook the v1 DESIGN reserved, triggered by the first +concrete consumer requirement: the BSS rating pipeline's usage ingestion. + +Terminology note: *emission* in the v1 PRD means the **inbound** submission of records by usage +sources; that meaning is unchanged. The outbound surface is named the **Usage Event Feed**. + +### 1.2 Background / Problem Statement + +The v1 Usage Collector is intentionally synchronous request/response on every public surface; +near-real-time consumers poll a Query SPI that is **eventually consistent with no upper bound** at +the gear floor, and read-after-write is guaranteed only via the ingestion ack (DESIGN §3.10, +ADR-0011). The v1 DESIGN reserved a push surface as "reserved-not-built — added once a concrete +consumer requirement and fan-out design land together" (DESIGN §4). + +That consumer requirement has landed. The BSS rating pipeline +(`gears/bss/rating/docs/design/12-usage-ingestion-normalization.md`) is contractually built on a +durable, at-least-once, per-partition-ordered usage transport with offsets and source replay — the +pattern billing-critical consumers require to guarantee "one accepted record contributes to a +rated charge at most once". The polled Query SPI cannot serve it: + +- **No freshness bound** — a cursor window over an eventually-consistent read can never be safely + closed. +- **Event-time cursors miss late-accepted records** — the raw query filters on `created_at`; a + record accepted late (with an older `created_at`) inserts *behind* an already-consumed cursor + position and is silently missed. +- **Retractions are invisible** — event deactivation is an operator write outside the ingestion + path; a poll of accepted records never observes it, so a downstream biller would keep charging + for a retracted record. + +The full seam analysis is recorded in the rating seam register, +`gears/bss/rating/docs/SEAMS.md` §J (UC1–UC6); UC1 gates the implementation of the rating +ingestion slice. + +### 1.3 Goals (Business Outcomes) + +| Goal | Success Measure | Baseline (v1) | +|------|-----------------|---------------| +| Unblock streaming consumption for billing-critical downstreams | The BSS rating pipeline ingests usage exclusively from the Usage Event Feed (no poll bridge, no dual-emit at sources); rating seam UC1 closed | No compliant transport exists; rating slice 12 blocked | +| End-to-end correction visibility | Every compensation and every deactivation (incl. depth-1 cascade) affecting an authorized scope is observable on the feed; zero silent retractions downstream | Deactivations invisible to any ingestion-path consumer | +| Reduced invoice adjustment churn | Downstream period closes gate on the ingestion watermark grace window; correction/adjustment volume attributable to ingestion lag drops | No watermark; every period close maximally provisional | + +### 1.4 Glossary + +Terms below extend the v1 PRD §1.4 glossary; v1 terms keep their meaning. + +| Term | Definition | +|------|------------| +| Usage Event Feed | The phase-2 **outbound** surface: a durable, resumable, per-tenant-ordered feed of accepted entries (usage records, compensation entries, status transitions) for downstream consumers ([§5.1](#51-usage-event-feed)). | +| Accepted Sequence | `acceptedSeq` — a per-tenant, strictly-increasing (gaps permitted) sequence number assigned to every accepted entry at acceptance time; the ordering coordinate of the feed. Distinct from the caller-supplied `created_at` event time. | +| Feed Cursor | A consumer-held position in the feed (per tenant partition, in `acceptedSeq` order) from which consumption resumes deterministically after restart or redelivery. | +| Status Transition Entry | A feed entry produced by an event deactivation (including each depth-1 cascaded compensation flip), carrying the affected record id and the prior/new `status`, with its own `acceptedSeq`. | +| Ingestion Watermark | A per-`(tenant, UsageType)` acceptance-progress marker: every entry with `acceptedSeq` at or below the watermark is feed-visible. Explicitly **not** an event-time completeness promise ([§5.2](#52-ingestion-watermarks)). | +| Feed Consumer | A downstream system consuming the Usage Event Feed ([§2.1](#21-new-system-actor)). | + +## 2. Actors + +### 2.1 New System Actor + +#### Feed Consumer + +**ID**: `cpt-cf-usage-collector-actor-feed-consumer` + +- **Role**: A downstream system consuming the ordered Usage Event Feed rather than (or in addition + to) the polled query surfaces. First concrete consumer: the BSS rating pipeline (usage + ingestion, `gears/bss/rating` design slice 12; seam register `gears/bss/rating/docs/SEAMS.md` + §J). +- **Needs**: Durable at-least-once delivery in accepted order, deterministic cursor replay after + restart, visibility of corrections and retractions (compensations and deactivations), and + per-`(tenant, UsageType)` ingestion watermarks. + +All v1 actors (PRD §2) are unchanged; the platform operator additionally operates the feed +surfaces, and existing usage consumers may adopt the feed without a contract change to their query +access. + +### 2.2 Actor Permissions (delta) + +| Actor | Permitted Operations | Denied by Default | +|-------|----------------------|-------------------| +| `cpt-cf-usage-collector-actor-feed-consumer` | Subscribe to and replay the Usage Event Feed for PDP-authorized tenant scopes; read ingestion watermarks for the same scopes | Consuming feed entries for tenants outside the PDP-authorized scope; mutating usage records or feed state; influencing ingestion admission or ordering | + +Authorization follows the v1 posture verbatim: enforced via the platform PDP on every operation, +fail-closed, no anonymous bypass, no cached decisions (v1 PRD §2.2, +`cpt-cf-usage-collector-contract-authz-resolver`). + +## 3. Operational Concept & Environment + +Three constraints shape every requirement in this document: + +- **Additive within v1.** All phase-2 surfaces are additive within REST v1 / SDK v1 under + `cpt-cf-usage-collector-adr-contract-stability` (ADR-0006); no v1 endpoint, trait method, or + schema changes incompatibly. "Phase 2" is a capability increment, not a major version. +- **The feed never taxes ingestion.** A downstream consumer's outage or slowness must not couple + into the ingestion path (the v1 DESIGN §4 reservation: "a push surface must not couple + downstream outages into ingestion"). Feed derivation and delivery are asynchronous to + acceptance; the phase-1 ingestion NFRs hold with the feed enabled. +- **Backend-agnostic by construction.** `cpt-cf-usage-collector-adr-pluggable-storage` admits + backends with no native change-feed (e.g. ClickHouse). Accepted order therefore derives from the + ingestion path (an accepted-sequence assigned at acceptance), never from a plugin-specific + change stream; the Plugin SPI gains an accepted-order scan capability, versioned per + `cpt-cf-usage-collector-nfr-plugin-contract-stability`. + +## 4. Scope + +### 4.1 In Scope + +- The Usage Event Feed: durable, resumable, per-tenant-ordered, at-least-once delivery of accepted + entries (usage records, compensation entries) and status transition entries +- Deterministic feed-cursor replay over the active plugin's retention window, including + new-consumer bootstrap +- Status transition entries for event deactivation, including the depth-1 cascade flips +- Ingestion watermarks per `(tenant, UsageType)` with a query surface and feed heartbeats +- PDP-authorized, tenant-scoped feed access +- The Plugin SPI accepted-order scan extension required for feed derivation + +### 4.2 Out of Scope + +- **Business logic** — pricing, rating, billing rules, invoice generation, quota decisions remain + downstream responsibilities (v1 PRD §4.2, unchanged) +- **Dimension-value carriage as a new capability** — none is needed: dimension values ride the + UsageType's declared closed-shape `metadata_fields` (a v1 capability) and are delivered on the + feed verbatim by `cpt-cf-usage-collector-fr-usage-event-feed`. The meter ↔ UsageType binding and + the dimension-set cross-validation are registry/pricing-side obligations (rating seam UC3), not + collector capabilities +- **A general business event bus** — the feed carries this gear's accepted entries only; platform + eventing topology is out of scope +- **Guaranteed event-time completeness** — the watermark is an acceptance-progress signal only; + records with arbitrarily old `created_at` remain acceptable (v1 posture, `domain-model.md` §2.1) +- **External reconciliation workflows** — remain out of scope for the gear entirely (v1 PRD §4.2) +- **Per-consumer delivery guarantees** — the gear guarantees feed availability and ordering; a + consumer's own consumption rate, offset management, and downstream processing are the + consumer's + +## 5. Functional Requirements + +### 5.1 Usage Event Feed + +#### Usage Event Feed + +- [ ] `p1` - **ID**: `cpt-cf-usage-collector-fr-usage-event-feed` + +The system **MUST** expose a durable, resumable Usage Event Feed of accepted entries: usage +records, compensation entries, and status transition entries. The system **MUST** assign every +accepted entry a per-tenant, strictly-increasing accepted sequence (`acceptedSeq`; gaps permitted) +at acceptance time, and the feed **MUST** deliver entries in `acceptedSeq` order within a tenant +partition. Delivery is **at-least-once**: a redelivered entry carries the same `acceptedSeq` and +canonical content. Every feed entry **MUST** carry the full canonical record — value, `created_at`, +attribution (tenant, resource, optional subject), `metadata` verbatim, `status`, `corrects_id` +where set — plus the identity tuple `(tenant_id, gts_id, idempotency_key)` and its `acceptedSeq`, +so a consumer needs no follow-up query to process an entry. The feed **MUST NOT** couple a +downstream consumer's outage or slowness into the ingestion path (feed derivation and delivery are +asynchronous to acceptance), and it **MUST** be available regardless of the active storage +backend's native change-feed capability (accepted order derives from the ingestion path, not from +a plugin-specific change stream). Feed access is PDP-authorized per tenant scope, consistent with +the v1 tenant-isolation posture on read surfaces (`cpt-cf-usage-collector-fr-tenant-isolation`). + +- **Rationale**: The first concrete downstream consumer (the BSS rating pipeline, rating design slice 12) is contractually built on a durable at-least-once ordered transport with replay — the pattern billing-critical consumers require to guarantee "one accepted record contributes to a rated charge at most once". Deriving order from acceptance (not event time) is what makes the cursor safe: a late-arriving `created_at` never inserts behind an already-consumed position. Keeping ingestion decoupled preserves the phase-1 ingestion NFRs and the fail-closed ingestion posture; keeping the feed backend-agnostic preserves `cpt-cf-usage-collector-fr-pluggable-storage`. +- **Actors**: `cpt-cf-usage-collector-actor-feed-consumer`, `cpt-cf-usage-collector-actor-usage-consumer` + +#### Feed Cursor Replay + +- [ ] `p1` - **ID**: `cpt-cf-usage-collector-fr-feed-cursor-replay` + +A feed consumer **MUST** be able to resume consumption from any retained cursor position, and +replay from a given cursor **MUST** be deterministic: the same cursor yields the same entries with +the same `acceptedSeq` and byte-identical canonical content, in the same order. The replay horizon +**MUST** equal the active storage plugin's record-retention window (the feed is derived from +retained records, not from a separate ephemeral buffer); a new consumer bootstraps by replaying +from the horizon start. A cursor before the horizon **MUST** be rejected with an actionable error +naming the earliest available position — never silently skipped forward. + +- **Rationale**: Restart-safety and new-consumer bootstrap without a parallel export path. Deterministic replay is the property downstream determinism contracts build on (the rating pipeline re-derives byte-identical normalized records from a source-stream replay); silent skip-forward would be invisible data loss for a billing consumer, which must instead surface the gap and decide explicitly. +- **Actors**: `cpt-cf-usage-collector-actor-feed-consumer` + +#### Status Transition Feed Entries + +- [ ] `p1` - **ID**: `cpt-cf-usage-collector-fr-feed-status-entries` + +Every event deactivation — including each depth-1 cascaded compensation flip +(`cpt-cf-usage-collector-fr-event-deactivation`) — **MUST** produce a status transition entry on +the Usage Event Feed with its own `acceptedSeq`, carrying the affected record id, the prior and +new `status`, and a reference to the operator action. A retraction **MUST NOT** be observable only +through the query surfaces: a feed consumer that never polls **MUST** still observe every +deactivation affecting its authorized scope. Compensation entries need no special treatment — they +ride the ingestion path and appear on the feed as accepted entries under +`cpt-cf-usage-collector-fr-usage-event-feed`. + +- **Rationale**: Deactivation is an operator-initiated write outside the ingestion path; without a feed entry it is invisible to a stream consumer, and a downstream biller would keep charging for a retracted record (rating seam UC5). Compensations already enter through ingestion, so the accepted-entry stream covers them by construction. +- **Actors**: `cpt-cf-usage-collector-actor-feed-consumer`, `cpt-cf-usage-collector-actor-platform-operator` + +### 5.2 Ingestion Watermarks + +#### Ingestion Watermark + +- [ ] `p2` - **ID**: `cpt-cf-usage-collector-fr-ingestion-watermark` + +The system **MUST** expose a per-`(tenant, UsageType)` ingestion watermark: a monotonic +acceptance-progress marker guaranteeing that every entry with `acceptedSeq` at or below the +watermark is feed-visible. The watermark **MUST** be exposed on a query surface and **SHOULD** be +carried periodically on the Usage Event Feed as a heartbeat entry (cadence and idle-tenant +behavior are DESIGN-level). The watermark describes **acceptance progress only**: it is explicitly +**not** an event-time completeness promise — records with older `created_at` values remain +acceptable at any later time (consistent with the v1 posture that old event timestamps are +accepted without wall-clock validation), and consumers **MUST** treat the watermark as a +lateness-reduction signal, never as a correctness gate. + +- **Rationale**: The rating consumer closes billing periods on time anchors and absorbs late usage through its correction machinery; a watermark lets it hold a short grace window before finalizing usage lines, cutting adjustment churn on invoices without introducing a completeness dependency the collector cannot honestly promise (rating seam UC2). Scoping the promise to acceptance progress keeps the contract truthful under unbounded event-time lateness. +- **Actors**: `cpt-cf-usage-collector-actor-feed-consumer`, `cpt-cf-usage-collector-actor-usage-consumer` + +## 6. Non-Functional Requirements + +#### Feed Lag and Ingestion Non-Regression + +- [ ] `p2` - **ID**: `cpt-cf-usage-collector-nfr-feed-lag` + +Under the `cpt-cf-usage-collector-nfr-throughput-profile` load envelope, the lag from record +acceptance to feed visibility **MUST** be ≤ 5 seconds at p95 (**provisional pending the phase-2 +NFR review**; the bound is a freshness target for the feed, not a delivery guarantee to any given +consumer, whose own consumption rate is outside the gear's control). Enabling the Usage Event Feed +**MUST NOT** regress the phase-1 ingestion NFRs: `cpt-cf-usage-collector-nfr-ingestion-latency` +(p95 ≤ 200ms) and `cpt-cf-usage-collector-nfr-ingestion-throughput` (≥ 10,000 records/sec) hold +with the feed enabled and consumers attached. + +- **Rationale**: The feed's value to a billing-critical consumer is near-real-time correction visibility; an unbounded lag degrades it to a batch export. The non-regression clause preserves the phase-1 contract for every existing usage source: the outbound surface is paid for by the feed path, never by the ingestion path (the decoupling constraint of `cpt-cf-usage-collector-fr-usage-event-feed`). + +The v1 NFR exclusions (PRD §6.2) apply to phase 2 unchanged. + +## 7. Public Library Interfaces + +### 7.1 Surface Additions + +All additions follow the wire shape the v1 DESIGN §4 reserved and are additive within REST v1 / +SDK v1 (ADR-0006): + +- **REST**: SSE `GET /usage-collector/v1/records/stream` with cursor resume; a cursor-paged + catch-up endpoint for deterministic replay; a watermark read endpoint. Authoritative wire + schemas land in `usage-collector-v1.yaml` at design time. +- **SDK trait**: a `Stream`-returning feed-subscription method and a watermark read method, added + with default implementations per the v1 SDK breaking-change policy. +- **Plugin SPI**: an accepted-order scan capability (read retained entries in `acceptedSeq` order + from a cursor) — the feed-derivation primitive; versioned and coordinated per + `cpt-cf-usage-collector-nfr-plugin-contract-stability`. + +### 7.2 Usage Event Feed Contract + +- [ ] `p1` - **ID**: `cpt-cf-usage-collector-contract-usage-event-feed` + + + +**Direction**: provided by library (ordered outbound feed consumed by downstream feed consumers; first named consumer: the BSS rating pipeline — `gears/bss/rating` design slice 12, seam register `gears/bss/rating/docs/SEAMS.md` §J) +**Protocol/Format**: the Usage Event Feed surfaces per [§7.1](#71-surface-additions) — SSE with cursor resume, a cursor-paged catch-up endpoint for deterministic replay, and a `Stream`-returning SDK method. Entries are delivered at-least-once, ordered by `acceptedSeq` within a tenant partition, each carrying the full canonical record + identity tuple `(tenant_id, gts_id, idempotency_key)` + `acceptedSeq` (`cpt-cf-usage-collector-fr-usage-event-feed`). +**Consumed / Provided Data**: consumers supply a PDP-authorized subscription scope and a feed cursor; the collector provides accepted entries (usage, compensation), status transition entries, and watermark heartbeats. Consumer obligations: consumption is idempotent on the identity tuple (a redelivery is absorbed, never double-processed); any downstream deduplication key **MUST** embed the `gts_id` scope (an idempotency key legitimately recurs across UsageTypes — rating seam UC4); `metadata` is passed through verbatim and interpreted downstream. Business logic (pricing, rating, invoice generation, quota decisions) remains outside the collector. +**Availability / Fallback**: a feed outage or slow consumer never blocks or degrades ingestion (`cpt-cf-usage-collector-nfr-feed-lag` non-regression clause); on reconnect a consumer resumes from its cursor via the catch-up endpoint (`cpt-cf-usage-collector-fr-feed-cursor-replay`). Consumers **MUST NOT** invent entries for gaps; a pre-horizon cursor is an explicit error. +**Compatibility**: additive within REST v1 / SDK v1 per `cpt-cf-usage-collector-adr-contract-stability` (ADR-0006) and `cpt-cf-usage-collector-nfr-plugin-contract-stability`. The Plugin SPI extension required for feed derivation (accepted-order scan) is versioned and coordinated per the same stability NFR. + + + +## 8. Use Cases + +#### Consume the Usage Event Feed + +- [ ] `p1` - **ID**: `cpt-cf-usage-collector-usecase-consume-event-feed` + +**Actor**: `cpt-cf-usage-collector-actor-feed-consumer` + +**Preconditions**: + +- Actor is authenticated with a valid SecurityContext and PDP-authorized for the subscribed tenant + scope +- The active storage plugin supports the accepted-order scan capability + +**Main Flow**: + +1. Consumer subscribes to the feed for its authorized scope, presenting its last committed cursor + (or none, for bootstrap) +2. System authorizes the subscription via PDP and streams entries from the cursor in `acceptedSeq` + order: accepted usage and compensation entries, status transition entries, and watermark + heartbeats +3. Consumer processes each entry idempotently on the identity tuple and periodically commits its + cursor +4. On restart or reconnect, the consumer resumes from its committed cursor; redelivered entries + are absorbed by idempotent consumption + +**Postconditions**: + +- The consumer has observed every accepted entry and every status transition in its authorized + scope, exactly once by identity tuple +- The consumer's cursor advances monotonically; no entry in scope is silently skipped + +**Alternative Flows**: + +- **Authorization denied / scope narrowed**: subscription rejected or constrained per PDP; no + entries outside the authorized scope are delivered +- **Pre-horizon cursor**: system rejects with an actionable error naming the earliest available + position; the consumer decides explicitly (bootstrap replay or operational escalation) +- **Slow consumer / feed backlog**: ingestion is unaffected; the consumer catches up via the + cursor-paged endpoint + +#### Gate a Downstream Period Close on the Ingestion Watermark + +- [ ] `p2` - **ID**: `cpt-cf-usage-collector-usecase-watermark-close-gate` + +**Actor**: `cpt-cf-usage-collector-actor-feed-consumer` + +**Preconditions**: + +- The consumer operates a time-anchored close process (e.g. a billing period close) downstream + +**Main Flow**: + +1. At its close anchor, the consumer reads the ingestion watermark for the relevant + `(tenant, UsageType)` pairs +2. The consumer holds a bounded grace window until the watermark passes its close-relevant + position (or the window expires) +3. The consumer finalizes its close; any usage accepted later is handled by the consumer's own + correction machinery + +**Postconditions**: + +- The close consumed all feed-visible usage up to the watermark; residual lateness is bounded to + genuinely late acceptance, not to feed lag + +**Alternative Flows**: + +- **Watermark unavailable**: the consumer proceeds on its own schedule — the watermark is a + lateness-reduction signal, never a correctness gate (`cpt-cf-usage-collector-fr-ingestion-watermark`) + +## 9. Acceptance Criteria + +The §9.0 load-envelope and measurement definitions of the v1 PRD apply verbatim to every numeric +criterion below. + +- [ ] Every accepted usage record and compensation entry appears on the Usage Event Feed exactly once per `acceptedSeq`, in per-tenant `acceptedSeq` order, carrying the full canonical record, the identity tuple `(tenant_id, gts_id, idempotency_key)`, and its `acceptedSeq` (cross-reference `cpt-cf-usage-collector-fr-usage-event-feed`) +- [ ] Feed delivery is at-least-once and redeliveries are byte-identical: a consumer that dedups on the identity tuple observes each entry's effect exactly once (cross-reference `cpt-cf-usage-collector-fr-usage-event-feed`) +- [ ] `acceptedSeq` is strictly increasing per tenant (gaps permitted) and is assigned at acceptance: a record accepted later with an older `created_at` receives a higher `acceptedSeq` and is delivered ahead of no already-delivered position (cross-reference `cpt-cf-usage-collector-fr-usage-event-feed`) +- [ ] Resuming from a committed cursor is deterministic: the same cursor yields the same entries, same order, byte-identical content; a pre-horizon cursor is rejected with an actionable error naming the earliest available position, and the replay horizon equals the active plugin's record-retention window (cross-reference `cpt-cf-usage-collector-fr-feed-cursor-replay`) +- [ ] Every deactivation — including each depth-1 cascaded compensation flip — produces a status transition entry with its own `acceptedSeq`; a feed consumer that never polls the query surfaces still observes every retraction in its authorized scope (cross-reference `cpt-cf-usage-collector-fr-feed-status-entries`) +- [ ] The ingestion watermark per `(tenant, UsageType)` is monotonic and guarantees feed visibility of every entry at or below it; documentation and wire contract state that it is not an event-time completeness promise (cross-reference `cpt-cf-usage-collector-fr-ingestion-watermark`) +- [ ] Feed subscription and watermark reads are PDP-authorized and tenant-scoped; entries outside the authorized scope are never delivered (cross-reference `cpt-cf-usage-collector-fr-usage-event-feed`) +- [ ] Acceptance-to-feed-visibility lag is ≤ 5s at p95 under the load envelope (provisional pending the phase-2 NFR review), and the phase-1 ingestion NFRs (p95 ≤ 200ms, ≥ 10,000 records/sec) hold with the feed enabled and consumers attached (cross-reference `cpt-cf-usage-collector-nfr-feed-lag`) +- [ ] The feed operates unchanged over any active storage plugin implementing the accepted-order scan capability, including backends without a native change-feed (cross-reference `cpt-cf-usage-collector-fr-usage-event-feed`) + +## 10. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| authz-resolver | Platform PDP; authorizes feed subscription, replay, and watermark reads (same fail-closed posture as v1) | p1 | +| Storage plugins | Accepted-order scan capability (Plugin SPI extension) from the active plugin; coordinated per the stability NFR | p1 | +| BSS rating pipeline | Driving consumer; co-reviews the feed contract and the dedup-key derivation (rating SEAMS §J UC1–UC6) | p1 | +| Platform gateway | Long-lived SSE connections through the gateway; the cursor-paged catch-up endpoint is the fallback lane if streaming is constrained | p2 | + +## 11. Assumptions + +| Assumption | Owner | Validation | +|------------|-------|------------| +| Phase-2 surfaces are additive within REST v1 / SDK v1; no v1 consumer or plugin breaks (ADR-0006) | Usage Collector Maintainers | Contract-diff check against `usage-collector-v1.yaml`; plugin compatibility suite | +| The active storage plugins on the v1 roadmap can implement an accepted-order scan (per-tenant monotonic sequence readable from a cursor) | Plugin Authors / Usage Collector Maintainers | SPI design review with each plugin; capability probe at gear readiness | +| The BSS rating pipeline is the first feed consumer and validates the contract end-to-end before general availability | BSS Rating Team | Joint integration fixture: feed → rating slice-12 ingestion → dedup/Q assertions | +| The platform gateway sustains long-lived streaming connections or the deployment falls back to cursor-paged catch-up without contract change | Platform Edge Team | Gateway streaming test; fallback drill | + +## 12. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Per-tenant monotonic `acceptedSeq` needs a serialization point under multi-instance ingestion; a naive global sequence would throttle the ingestion hot path | Ingestion throughput regression — a direct violation of the non-regression clause | Sequence scope is per tenant (not global) with gaps permitted; assignment strategy is per-backend DESIGN work (e.g. transactional outbox on PG-class backends, insert-ordered scan on CH-class); load-test the envelope with the feed enabled (`cpt-cf-usage-collector-nfr-feed-lag`) | +| The Plugin SPI extension lands across independently-released plugins; a deployment may run a plugin without the accepted-order scan | Feed unavailable on that deployment while ingestion continues; consumer onboarding blocked | Capability-gated readiness (the feed advertises unavailability explicitly rather than degrading silently); coordinated SPI release per the stability NFR with a migration window | +| Replay horizon varies per deployment (plugin retention profile) | A new/recovering consumer may not reconstruct history older than the horizon | Deployment guide publishes the horizon; the first consumer (rating) persists normalized records immediately on consumption (mediation posture), so its correction horizon does not depend on the collector's retention | +| Watermark misread as a completeness promise by future consumers | Downstream closes treat late events as errors instead of corrections | Contract wording is explicit ("acceptance progress only"); the watermark FR obliges consumers to treat it as a lateness-reduction signal; rating-side handling is already correction-based | + +## 13. Open Questions + +- **Feed partition granularity** — per `tenant_id` (proposed) vs per `(tenant_id, gts_id)`; and + correspondingly whether `acceptedSeq` is scoped per tenant or per partition. Per-tenant matches + the first consumer's partitioning (the rating pipeline partitions on the ordering tenant and + re-orders internally); finer partitions raise fan-out and watermark cardinality. +- **Canonical delivery lane** — SSE-with-resume as canonical with cursor-paged GET derived, or the + inverse. The deterministic-replay obligation binds whichever lane is canonical. +- **`acceptedSeq` assignment mechanics per backend** — transactional outbox vs insert-ordered scan + per plugin family; where the sequence is minted when ingestion runs multi-instance. DESIGN + phase-2 work with plugin authors. +- **Watermark heartbeat cadence and idle-tenant behavior** — fixed cadence vs on-progress-only; + how an idle `(tenant, UsageType)` communicates "no progress, nothing pending". +- **Feed fan-out limits** — whether feed subscriptions need per-consumer rate/fan-out controls, + and how that composes with the (still-deferred) v1 rate-limiting item. + +## 14. Traceability + +- **Driving consumer requirement**: `gears/bss/rating/docs/SEAMS.md` §J — UC1 (transport; gates + rating slice-12 implementation), UC2 (watermark), UC4 (dedup-key derivation), UC5 (correction + visibility); rating design slice + `gears/bss/rating/docs/design/12-usage-ingestion-normalization.md`. +- **Reserved hooks executed**: v1 [DESIGN.md](./DESIGN.md) §4 deferral table — "Push / subscribe + surface for downstream readers" (SSE + SDK `Stream` hook; "a push surface must not couple + downstream outages into ingestion"; pluggable storage admits backends with no native + change-feed) and "Rate limiting & watermarks"; v1 [PRD.md](./PRD.md) §4.2 "Watermark and + Reconciliation Metadata" ("covered in a later phase"). +- **Stability envelope**: `cpt-cf-usage-collector-adr-contract-stability` (ADR-0006), + `cpt-cf-usage-collector-nfr-plugin-contract-stability`. +- **Consistency baseline being complemented (not changed)**: + `cpt-cf-usage-collector-adr-consistency-contract` (ADR-0011) — the polled Query SPI remains + eventually consistent with no upper bound; the feed adds the ordered surface next to it. +- **v1 correction primitives carried onto the feed**: + `cpt-cf-usage-collector-fr-usage-compensation` (ADR-0008), + `cpt-cf-usage-collector-fr-event-deactivation` (ADR-0005).