Files
member-console/design/documents/doc-31-amendment-3-draft.md
T
cgalo5758 bfe9cee0fe Consolidate design docs into documents directory
- Remove per-module projection files (README, architecture, companion,
  interfaces, model) under design/<module>/
- Add design/documents/ with numbered design docs, references, policies,
  and manifest
- Update design/README.md to describe the directory as a mirror of
  membcons-db's normative surfaces
- Record Decisions 140-141 in companion and glossary; update
  data-model.md schema organization
2026-08-21 00:55:42 -05:00

16 KiB

Document 31 — Amendment #3 (draft)

Date drafted: 2026-04-19 Status: Draft for review. Not yet merged into doc-31-plan-enrollment-recommendation.md. Supersedes: the §10 step 7 deferral, Amendment #1's product_type_plan_iff_ladder_member CHECK, and the "single-ladder convenience path" referenced in §10 step 2. Depends on: Amendment #1 (2026-04-17). This amendment is a refinement of the shape Amendment #1 established, not a reversal of it.


Summary

Two denormalizations introduced or retained by Amendment #1 are retired:

  1. The products.plan_ladder_id column is dropped in its entirety — a "single-ladder convenience path" that maintained ladder membership in parallel with plan_ladder_tiers.
  2. The 'plan' value is removed from billing.product_type_enum — a label that duplicated the structural fact of having tier rows. The column itself is preserved and continues to distinguish the remaining non-plan kinds ('addon', 'usage', 'one_time'), whose structural derivations are out of scope for this amendment and deferred to future amendments that can identify their distinguishing structural facts.

The bidirectional product_type_plan_iff_ladder_member CHECK introduced by Amendment #1 is retired alongside them. The §10 step 7 deferral — which postponed a predicate refinement to the amendment accompanying the first real bundle product — is closed not by refining the predicate, but by dissolving the conditions that made the predicate necessary. A design-time amendment is preferable to a reactive one: the platform serves unknown future cooperatives whose commercial topologies are not yet disclosed, and allowing the first concrete bundle to dictate the shape of the predicate would be indistinguishable from letting the first customer dictate the schema.


Rationale

The dual-representation problem

Amendment #1 moved multi-ladder bundle membership into plan_ladder_tiers while retaining products.plan_ladder_id as a convenience column for the single-ladder case. Two representations of the same underlying fact — ladder membership — were thereby kept in parallel, distinguished by the topology of the product (single-ladder via the FK, multi-ladder via the junction). Reflection surfaces this as a privileging of single-ladder topology that sits uneasily beside the platform's stated design stance:

The platform serves unknown future cooperatives with diverse institutional arrangements. Assumptions about what any single cooperative needs are unreliable. The schema is designed for the general case.

A convenience optimized for a topology whose prevalence is not yet known is not a convenience — it is a bet. The bet may prove correct, but the cost of its incorrectness (a denormalization whose drift requires a CHECK to police) is paid upfront, whereas the savings (one fewer join on a common read path) are recovered only if the bet pays.

The label-vs-structure redundancy

A parallel redundancy exists in products.product_type. The 'plan' value is not a fact independent of the relational graph; it is a claim that the product has rows in plan_ladder_tiers. Amendment #1 made the structural claim authoritative (plan_ladder_tiers as the source of truth) and then layered a bidirectional CHECK over the enum label to keep label and structure in agreement. The CHECK is necessary only because the denormalization exists.

This is the same pattern as plan_ladder_id, one layer up: every product_type value is implicitly a claim about the product's relational shape. 'plan' means "has tier rows"; 'one_time' means "has a non-recurring price"; 'service' means "has some service-fulfillment configuration." Where the relational graph is authoritative, the label is a lagging denormalization, and an invariant must be enforced to keep it from drifting. Removing the denormalization removes the invariant.

The bundle-predicate deferral as symptom, not problem

The §10 step 7 deferral concerned a CHECK that would need to accept either plan_ladder_id IS NOT NULL or a non-empty tier-row set. The two candidate implementations — a deferrable circular FK nominating a "canonical tier," or a constraint trigger at COMMIT — are both philosophically costly. The first reintroduces tier privileging (the very anti-pattern the junction was meant to eliminate). The second expands the procedural surface that Amendment #1's own companion-decision #118 went to some lengths to keep at a single point (the AFTER UPDATE trigger on pool_provision_ladders, described as "the sole procedural element of the design").

The deferral is therefore a symptom of an over-constrained design space: any predicate refinement is costly because the predicate itself should not exist. This amendment attacks the predicate's premise rather than its form.


Schema changes

1. Retire products.plan_ladder_id

The column is dropped. All ladder membership — single-ladder, multi-ladder, and every intermediate case — is expressed exclusively through billing.plan_ladder_tiers. A single-ladder plan is a plan whose tier-row set has cardinality one; no structural distinction separates it from a bundle beyond that cardinality, and the schema carries none.

ALTER TABLE billing.products
  DROP CONSTRAINT product_type_plan_iff_ladder_member,
  DROP COLUMN plan_ladder_id;

2. Retire the 'plan' value from product_type

The valid domain of product_type is narrowed to the non-plan kinds — 'addon', 'usage', and 'one_time'. "Plan" is no longer a label a product bears; it is a structural fact about the product's relational shape, specifically the presence of rows in plan_ladder_tiers. The column itself is retained for the remaining kinds; only the 'plan' value is removed from its domain.

ALTER TABLE billing.products
  ADD CONSTRAINT product_type_non_plan_domain
  CHECK (product_type IS NULL OR product_type IN ('addon', 'usage', 'one_time'));

The IS NULL branch is load-bearing, not decorative. Plan products carry product_type = NULL (their kind is derived structurally from plan_ladder_tiers presence), and draft products may carry NULL until a kind is declared or tiers are wired. Writing the CHECK with only product_type IN (...) would rely on PostgreSQL's three-valued logic — NULL IN (...) evaluates to NULL, which a CHECK treats as permissive — but expressing it explicitly documents the intent and prevents a later reviewer from "tightening" the CHECK to reject NULLs.

product_type is currently declared as VARCHAR(50) (per modules/billing/model.md:82), so domain narrowing is expressed as a CHECK. If the column is later promoted to a named enum type, the 'plan' value is simply absent from that declaration and the column's nullability is preserved at the column level.

3. Derived kind via view

A view exposes product kind for query ergonomics, without reintroducing the denormalization. Each kind in the current taxonomy receives an explicit case; no fall-through is employed. This makes the view an auditable taxonomy artifact: reading it enumerates every kind the catalog recognizes and the rule by which each is recognized.

CREATE VIEW billing.product_kinds AS
SELECT
  p.product_id,
  CASE
    WHEN EXISTS (
      SELECT 1 FROM billing.plan_ladder_tiers t
      WHERE t.product_id = p.product_id
    ) THEN 'plan'
    WHEN p.product_type = 'addon'    THEN 'addon'
    WHEN p.product_type = 'usage'    THEN 'usage'
    WHEN p.product_type = 'one_time' THEN 'one_time'
  END AS product_kind
FROM billing.products p;

The plan case is the only one whose derivation is currently structural: it reads from the presence of rows in plan_ladder_tiers. The three non-plan cases delegate to the product_type column — an honest acknowledgement that their structural derivations have not yet been designed. Each non-plan case is a promissory note: when the schema develops the structural fact that distinguishes addon, usage, or one_time at the relational level, the corresponding WHEN clause is rewritten to read from that fact. Until then, the product_type column serves as the provisional source of truth for those kinds.

This discipline — that a kind must eventually earn structural derivation, with the view as the ledger of progress — is the taxonomy's development invariant. A new kind introduced without a structural fact is, by this discipline, not yet a kind; it is a label awaiting a relation to anchor it. The view's precedence ordering (structural plan derivation first, enum delegation second) ensures that the transition of any kind from label to structural derivation is a local edit to one WHEN clause, not a reshuffle of the taxonomy.

Scope note on non-plan structural derivations

Concrete sketches of what each non-plan derivation might eventually look like — recorded here as awareness notes, not commitments:

  • one_time: plausibly derivable from prices.recurring_interval IS NULL. Complication: a product may own multiple prices of mixed shapes (recurring and one-time) under the current schema, so the derivation requires a prior design decision about per-product price-kind uniqueness. Until that decision, derivation is premature.
  • usage: plausibly derivable from prices.usage_type IN ('metered', 'licensed'). Subject to the same multiple-prices ambiguity as one_time.
  • addon: has no clean structural signal in the current schema. "Supplementary capability" is an editorial category rather than a relational fact. A future structural derivation would require introducing a new relation (e.g., an addon_of FK positioning the addon against a principal product, or a distinct flavor of entitlement-set composition) that the schema does not yet carry.

These sketches are not decisions. They are notes for whoever drafts the next amendment in this lineage.

4. Retire the product_type_plan_iff_ladder_member CHECK

The biconditional introduced by Amendment #1 is dropped (see §1 above for the combined DDL). Its enforcement target — agreement between label and structure — no longer exists, because there is no longer a label for the structure to agree with. The §10 step 7 deferral is closed by this retirement.

5. Introduce lifecycle_status on products

Concerns about intent before structure — the catalog editor who wishes to draft a plan before wiring up its tiers — are addressed by an orthogonal lifecycle axis rather than by preserving the label. The column follows the existing billing-module convention of VARCHAR(20) with CHECK-narrowed domain (consistent with billing_accounts.status, platform_billing_mode, and other enum-like columns in the module):

ALTER TABLE billing.products
  ADD COLUMN lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'draft',
  ADD CONSTRAINT lifecycle_status_domain
    CHECK (lifecycle_status IN ('draft', 'published', 'retired'));

A draft product has no kind because it has no structural commitments yet. Publication is a separate transition, gated by whatever editorial or commercial checks the platform surfaces at that boundary. The separation of intent (lifecycle) from kind (structure) is cleaner than the conflation that the product_type label previously enforced; it also generalizes — lifecycle applies equally to non-plan kinds.


Out of scope

This amendment does not introduce or resolve questions of exclusivity between structural commitments — for example, whether a product may simultaneously carry tier rows and a non-recurring price. The view in §3 resolves kind by precedence (tier rows dominate), which is sufficient for current reads but does not enforce that contradictory commitments cannot coexist in the relational graph. If that invariant becomes important, it is the subject of a future amendment, independent of the changes here.


Revisions to Doc 31

§10 step 2 — revised

2. All ladder membership is expressed through billing.plan_ladder_tiers. No direct FK from products to plan_ladders is retained; a plan's cardinality on the ladder axis is read from the junction in every case, with no topological distinction between single-ladder plans and multi-ladder bundles. The plan_ladder_tiers junction is authoritative in all cases.

§10 step 7 — revised

7. Structural derivation of product kind. products.plan_ladder_id is dropped. products.product_type is narrowed to ('addon', 'usage', 'one_time') by CHECK constraint; the 'plan' value is removed from the domain, while the column itself is preserved for the remaining non-plan kinds. billing.product_kinds is the authoritative view over product kind, with an explicit case per kind — 'plan' derived structurally from plan_ladder_tiers presence, the three non-plan cases delegating to product_type as provisional sources of truth pending future structural derivations. lifecycle_status is introduced on products to carry the draft-vs-published axis orthogonally to kind. The bidirectional product_type_plan_iff_ladder_member CHECK introduced by Amendment #1 is retired. The multi-ladder bundle predicate refinement previously deferred from this step is retired by schema dissolution rather than by predicate refinement; no trigger or deferrable-FK mechanism is required.

Amendment-log entry (to be inserted between current Amendment #2 and the "---" divider on line 23)

Amendment #3 — 2026-04-19. Two denormalizations retired. (a) products.plan_ladder_id is dropped entirely; plan_ladder_tiers is the sole representation of ladder membership for all topologies, single-ladder and multi-ladder alike. (b) The 'plan' value is removed from the products.product_type domain (narrowed via CHECK to 'addon' | 'usage' | 'one_time'); product kind becomes a structural derivation exposed through a billing.product_kinds view, with explicit per-kind cases and no fall-through. The plan case derives from plan_ladder_tiers presence; non-plan cases delegate to product_type as provisional sources of truth pending future structural derivations of those kinds. The bidirectional product_type_plan_iff_ladder_member CHECK introduced by Amendment #1 is retired, closing the §10 step 7 deferral by dissolution rather than refinement. A lifecycle_status enum is introduced on products to carry the draft-vs-published axis that the product_type label had previously conflated with kind. See §10 steps 2 and 7 (revised).


Downstream documentation updates

Landing this amendment entails corresponding edits in:

  • modules/billing/model.mdproducts table definition (drop plan_ladder_id, adjust product_type commentary, add lifecycle_status), plan_ladder_tiers prose (remove bundle-caveat special-casing, state junction universality), plan_ladders relationships block (remove the "single-ladder convenience path" bullet), retire the product_type_plan_iff_ladder_member CHECK block, add the product_kinds view definition.
  • modules/data-model.md — mirror the above on the consolidated schema document.
  • modules/companion.md — mark Amendment #1's CHECK decision (row 117) as superseded; add decisions for the view-based kind derivation, retirement of the convenience path, and lifecycle_status.
  • modules/entitlements/model.md — verify no text references the retired plan_ladder_id (the junction-to-pool flow already routes through plan_ladder_tiers, so no logical change is expected; a textual pass is nevertheless required).

Review decisions (resolved 2026-04-19)

  1. View form. Explicit per-kind WHEN clauses, no fall-through. Resolved in §3.
  2. lifecycle_status scope. Retained in this amendment rather than deferred. The motivation is tightly coupled to the retirement of 'plan', and separating them would leave the "intent before structure" gap unaddressed between amendments.

Residual open question

Whether the non-plan kinds' eventual structural derivations (sketched in §3's scope note) should each become their own Doc-31 amendment, or whether they should migrate to a successor document (a Doc 3x dedicated to product-kind taxonomy) once Doc 31 is closed. Recommendation pending; raise at the next amendment.

Framing carried forward by doc-35-product-kind-taxonomy.md (2026-05-12). Doc 35 assembles the column/view coexistence rationale into a standalone explanatory reference and elaborates the per-kind derivation sketches without resolving the successor-document question itself. The choice between amendment-chain continuation and successor-document migration is left to the moment the first non-plan derivation is ready to draft, when the scope of the work will indicate the right home (Doc 35 §6).