- 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
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:
- The
products.plan_ladder_idcolumn is dropped in its entirety — a "single-ladder convenience path" that maintained ladder membership in parallel withplan_ladder_tiers. - The
'plan'value is removed frombilling.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 fromprices.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 fromprices.usage_type IN ('metered', 'licensed'). Subject to the same multiple-prices ambiguity asone_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., anaddon_ofFK 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 fromproductstoplan_laddersis 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. Theplan_ladder_tiersjunction is authoritative in all cases.
§10 step 7 — revised
7. Structural derivation of product kind.
products.plan_ladder_idis dropped.products.product_typeis 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_kindsis the authoritative view over product kind, with an explicit case per kind —'plan'derived structurally fromplan_ladder_tierspresence, the three non-plan cases delegating toproduct_typeas provisional sources of truth pending future structural derivations.lifecycle_statusis introduced onproductsto carry the draft-vs-published axis orthogonally to kind. The bidirectionalproduct_type_plan_iff_ladder_memberCHECK 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_idis dropped entirely;plan_ladder_tiersis the sole representation of ladder membership for all topologies, single-ladder and multi-ladder alike. (b) The'plan'value is removed from theproducts.product_typedomain (narrowed via CHECK to'addon' | 'usage' | 'one_time'); product kind becomes a structural derivation exposed through abilling.product_kindsview, with explicit per-kind cases and no fall-through. Theplancase derives fromplan_ladder_tierspresence; non-plan cases delegate toproduct_typeas provisional sources of truth pending future structural derivations of those kinds. The bidirectionalproduct_type_plan_iff_ladder_memberCHECK introduced by Amendment #1 is retired, closing the §10 step 7 deferral by dissolution rather than refinement. Alifecycle_statusenum is introduced onproductsto carry the draft-vs-published axis that theproduct_typelabel 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.md—productstable definition (dropplan_ladder_id, adjustproduct_typecommentary, addlifecycle_status),plan_ladder_tiersprose (remove bundle-caveat special-casing, state junction universality),plan_laddersrelationships block (remove the "single-ladder convenience path" bullet), retire theproduct_type_plan_iff_ladder_memberCHECK block, add theproduct_kindsview 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, andlifecycle_status.modules/entitlements/model.md— verify no text references the retiredplan_ladder_id(the junction-to-pool flow already routes throughplan_ladder_tiers, so no logical change is expected; a textual pass is nevertheless required).
Review decisions (resolved 2026-04-19)
- View form. Explicit per-kind
WHENclauses, no fall-through. Resolved in §3. lifecycle_statusscope. 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).