Remove plan safety and architecture docs
This commit is contained in:
@@ -17,8 +17,23 @@ docker-push:
|
||||
# Database migration targets
|
||||
.PHONY: sqlc-generate
|
||||
|
||||
# The nine package directories that carry their own sqlc.yaml (six core
|
||||
# packages + three integration stores); see docs/database-management.md.
|
||||
SQLC_DIRS = internal/identity \
|
||||
internal/organization \
|
||||
internal/billing \
|
||||
internal/entitlements \
|
||||
internal/integration \
|
||||
internal/domains \
|
||||
internal/integrations/fedwiki/store \
|
||||
internal/integrations/stripe/store \
|
||||
internal/integrations/discourse/store
|
||||
|
||||
sqlc-generate:
|
||||
cd internal/db && sqlc generate
|
||||
@for dir in $(SQLC_DIRS); do \
|
||||
echo "sqlc generate: $$dir"; \
|
||||
(cd $$dir && sqlc generate) || exit 1; \
|
||||
done
|
||||
|
||||
# Build the application
|
||||
.PHONY: build
|
||||
|
||||
@@ -49,13 +49,11 @@ List the primary audience first; a doc may name more than one.
|
||||
- [Design System](design-system.md) — UI conventions and the accessibility baseline.
|
||||
- [Domain Model Cards](models/README.md) — the model catalog: one card per domain model with invariants, dimensions, and traps.
|
||||
- [FedWiki Integration](fedwiki-setup.md) — provisioning via the FarmManager API.
|
||||
- [Grant / Plan Ladder Safety](grant-plan-safety.md) — the invariants that protect entitlements.
|
||||
- [HTMX Setup](htmx-setup.md) — HTMX under a strict CSP.
|
||||
- [Operator Information Architecture](operator-ia.md) — the operator panel's IA contract.
|
||||
- [Operator UX Conventions](operator-ux-conventions.md) — form and action conventions.
|
||||
- [Operator UX Research](operator-ux-research.md) — the M7 UX research study.
|
||||
- [Operator UX Walkthrough Task](operator-ux-walkthrough-task.md) — the screen-walkthrough briefing.
|
||||
- [Plan Architecture](plan-architecture.md) — how plan enrollment spans billing and entitlements.
|
||||
- [Building an Integration](building-an-integration.md) — adding a new integration.
|
||||
- [Testing](testing.md) — the test taxonomy and how to run each kind.
|
||||
- `operator-ux-walkthroughs/`, `operator-ux-walkthrough-evidence/` — captured UX walkthrough evidence.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: "Grant / Plan Ladder Safety"
|
||||
audience: [developer]
|
||||
summary: "Past grant and plan-ladder safety incidents, and the database safeguards (GiST exclusion constraint, sync trigger) that prevent them."
|
||||
---
|
||||
|
||||
# Grant / Plan Ladder Safety
|
||||
|
||||
> **Last updated:** 2026-04-26
|
||||
>
|
||||
> **See also:** [`plan-architecture.md`](./plan-architecture.md) — where plan code lives and the sole-writer rule.
|
||||
|
||||
## Incident Memory
|
||||
|
||||
This document records past safety issues and how to recognize them. For the architecture and rules, see [`plan-architecture.md`](./plan-architecture.md). For operator-facing workflows, see [`plan-management.md`](./plan-management.md).
|
||||
|
||||
### 2026-04 — Two-Path Problem (plan-sole-writer-guards)
|
||||
|
||||
**What happened:** Member Console had two ways to create a grant that provisions a product to an organization. The general Grants tab (`CreateGrantAndMaterialize`) created a `pool_provisions` row but never touched `pool_provision_ladders` or wrote `pool_provision_transitions` audit rows. Operators could grant plan-tier products through the wrong tab, leaving the ladder system inconsistent.
|
||||
|
||||
**Gaps closed by `plan-sole-writer-guards` (archived at `openspec/changes/plan-sole-writer-guards/`):**
|
||||
|
||||
| Gap | Fix | Date |
|
||||
|-----|-----|------|
|
||||
| #1 — `CreateGrant` bypassing `Transition()` | `CreateGrant` now rejects plan-tier products with an error directing operators to the Enrollment tab. | 2026-04-26 |
|
||||
| #2 — `RevokeGrant` bypassing `Transition(End)` | `RevokeGrant` detects active ladder attachments and routes through `Transition(End)` instead of bare `RevokeGrantAndRematerialize`. | 2026-04-26 |
|
||||
| #3 — `IssueGrant` ladder state race | `IssueGrant` takes `FOR UPDATE` on the pool row before resolving ladder state, serializing concurrent grant issuance. | 2026-04-26 |
|
||||
|
||||
**How to recognize it again:** If a pool has an active `pool_provisions` row but no matching `pool_provision_ladders` attachment for a plan-tier product, the grant was issued through the wrong path. The fix is always to route plan-tier mutations through `entitlements.Transition()`.
|
||||
|
||||
## Database Safeguards
|
||||
|
||||
### 1. GiST Exclusion Constraint (`pool_provision_ladders`)
|
||||
|
||||
```sql
|
||||
ALTER TABLE pool_provision_ladders
|
||||
ADD CONSTRAINT no_overlapping_ladder_attachments
|
||||
EXCLUDE USING gist (
|
||||
pool_id WITH =,
|
||||
plan_ladder_id WITH =,
|
||||
active_during WITH &&
|
||||
);
|
||||
```
|
||||
|
||||
This prevents two **overlapping active** rows for the same `(pool_id, plan_ladder_id)`. It does **not** prevent a provision from existing without a ladder row, because the constraint lives on `pool_provision_ladders`, not `pool_provisions`.
|
||||
|
||||
### 2. Sync Trigger (`trigger_sync_pool_provision_ladders`)
|
||||
|
||||
Fires on `UPDATE` of `pool_provisions`:
|
||||
|
||||
- Propagates `status`, `activated_at`, `ended_at` to matching `pool_provision_ladders` rows.
|
||||
- Does nothing if **no ladder row exists** for that provision.
|
||||
|
||||
This means a provision created via `CreateGrantAndMaterialize` is invisible to the sync trigger until a ladder row is manually added.
|
||||
|
||||
## See Also
|
||||
|
||||
- `internal/entitlements/transitions.go` — `Transition()` implementation
|
||||
- `internal/entitlements/grants.go` — `CreateGrantAndMaterialize`, `RevokeGrantAndRematerialize`
|
||||
- `internal/server/operator_enrollment.go` — `IssueGrant`, `RevokeGrantAndTransition`, `ExtendGrant`
|
||||
- `internal/server/operator_partials.go` — `CreateGrant`, `RevokeGrant`
|
||||
@@ -36,15 +36,14 @@ The member console uses a single application-level role to gate access to the op
|
||||
|
||||
### How roles are extracted
|
||||
|
||||
The member console extracts roles from the **access token** by decoding the JWT payload and checking these claim locations in order:
|
||||
The member console extracts roles from the **access token** by decoding the JWT payload and reading every claim location an identity provider might use to carry roles, rather than trusting only one:
|
||||
|
||||
1. `roles` (top-level array) — used if present
|
||||
2. `groups` (top-level array) — fallback
|
||||
3. `realm_access.roles` (Keycloak realm roles) — final fallback
|
||||
1. `roles` (top-level array)
|
||||
2. `groups` (top-level array)
|
||||
3. `realm_access.roles` (Keycloak realm roles)
|
||||
4. `resource_access.<client-id>.roles` (Keycloak client-scoped roles, read only for this app's configured client ID; roles belonging to other clients are never included)
|
||||
|
||||
The first non-empty source wins.
|
||||
|
||||
> **Known limitation:** The code does not currently check `resource_access.<client-id>.roles`, which is where Keycloak stores client-specific role assignments. This is tracked as a future improvement to maximize compatibility across identity providers. See `status/issues.md`.
|
||||
The member console reads all four locations and merges their roles into one deduplicated list: a role that appears in more than one location is kept once, at the position where it first appears (the locations are read in the order listed above, and within a location, in the order the claim lists them). Because every location is read, a deployment can put `operator-member` in whichever claim its identity provider produces and the member console still recognizes it.
|
||||
|
||||
### Keycloak setup
|
||||
|
||||
@@ -76,7 +75,7 @@ This is simpler but mixes application roles with realm-level roles.
|
||||
|
||||
### Other identity providers
|
||||
|
||||
For providers other than Keycloak, ensure that the access token includes `operator-member` in one of the three checked claim locations (`roles`, `groups`, or `realm_access.roles`). Most providers support custom claims or group mappings that can achieve this.
|
||||
For providers other than Keycloak, ensure that the access token includes `operator-member` in one of the four checked claim locations (`roles`, `groups`, `realm_access.roles`, or `resource_access.<client-id>.roles`). Most providers support custom claims or group mappings that can achieve this.
|
||||
|
||||
## Temporal Authorization
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
title: "Plan Architecture"
|
||||
audience: [developer]
|
||||
summary: "How plan enrollment is distributed across the billing (catalog) and entitlements (runtime) modules, the sole-writer Transition primitive, and how to safely add plan functionality."
|
||||
---
|
||||
|
||||
# Plan Architecture
|
||||
|
||||
> **Audience:** Developers working on plan ladders, entitlement provisioning, operator UI, Stripe webhooks, or any feature that touches tier enrollment.
|
||||
>
|
||||
> **Last updated:** 2026-04-24
|
||||
|
||||
## What "plan" is in this codebase
|
||||
|
||||
"Plan" is not a Go package. It is a **capability slice** that threads through the existing domain packages, following a logical split that lives at the Go-package layer, not the database-schema layer (both packages' tables live in the single `core` Postgres schema — see `docs/database-management.md`): catalog data lives in `internal/billing`, runtime enrollment state lives in `internal/entitlements`. Any feature that mutates tier enrollment has to touch both — by design.
|
||||
|
||||
Because plan logic is distributed, the risk is drift: two callers mutating enrollment state in different ways, invariants enforced in one path but not another, audit rows written for some transitions and not others. This document exists to prevent that.
|
||||
|
||||
## The principle
|
||||
|
||||
The sole-writer rule is a *consequence* of one design choice, not a posture imposed on every product. The choice:
|
||||
|
||||
> **Plan membership is opt-in at the catalog layer. Once a product is opted in, it gets sole-writer semantics at runtime.**
|
||||
|
||||
A product is "a plan" only if it appears in a `plan_ladder_tiers` row. That membership is what brings the runtime invariants — one-active-tier-per-ladder, supersession on transition, audit row per change. Products that don't opt in (addons, usage, one-time) keep flat grant/revoke semantics with no ladder logic.
|
||||
|
||||
This means we do **not** privilege a particular commercial topology. A co-op that doesn't use ladders never touches this code path. A co-op that wants stacking grants, parallel enrollments, or gift-style products models them as non-plan products and they are unaffected by anything below. The opinionated machinery only kicks in for things explicitly modeled as tiered.
|
||||
|
||||
When a developer or operator finds the rule "in the way," the right question is almost always: *should this have been a plan in the first place?* If the answer is no, model it as an addon. If the answer is yes, the rule is what's keeping the data integrity intact, and the fix is to extend the primitive (e.g., add an explicit `extend` transition type) rather than to bypass it.
|
||||
|
||||
## The sole-writer rule
|
||||
|
||||
**`entitlements.Transition(ctx, tx, pool_id, target, actor)` is the sole writer of ladder enrollment state.** Every other component either calls it or reads from what it wrote.
|
||||
|
||||
- `Transition` is defined in `internal/entitlements/transitions.go`.
|
||||
- It classifies the event (`initiate`/`upgrade`/`downgrade`/`end`), ends the prior active provision, creates the new one, maintains the `pool_provision_ladders` attachment, writes the `pool_provision_transitions` audit row, and delegates to `ReapplyDefaultsForPool` on downgrade/end.
|
||||
- It is idempotent: re-invoking with the same target when the pool is already at that rank is a no-op.
|
||||
|
||||
If you find yourself writing to `pool_provisions`, `pool_provision_ladders`, or `pool_provision_transitions` from anywhere else, you are creating a bug. Stop and route through `Transition`.
|
||||
|
||||
See `docs/grant-plan-safety.md` for incident prevention detail and the three currently-documented gap cases at the UI boundary.
|
||||
|
||||
## Where plan code lives
|
||||
|
||||
| Layer | Package | Role |
|
||||
|-----------------------|-----------------------------------------------------------------|-----------------------------------------------|
|
||||
| Catalog package | `internal/billing/` | `plan_ladders`, `plan_ladder_tiers` tables (in `core`) + sqlc; product lifecycle |
|
||||
| Runtime package | `internal/entitlements/` | `pool_provision_ladders`, `pool_provision_transitions` tables (in `core`) + sqlc |
|
||||
| **Sole writer** | `internal/entitlements/transitions.go` | `Transition(pool, target, actor)` — only writer of enrollment state |
|
||||
| Default re-apply | `internal/entitlements/reapply_defaults.go` | `ReapplyDefaultsForPool`; resolves `default_plan_ladder_id` → rank-0 tier at use-time; NULL-default contract; supersession end-path |
|
||||
| Auto-provision hook | `internal/provisioning/provisioning.go` | Resolves the org type's `default_plan_ladder_id` rank-0 tier and creates the ladder attachment on org creation |
|
||||
| Webhook delegation | `internal/workflows/stripe/webhook_subscription.go` | `customer.subscription.created` / `.deleted` → `Transition` |
|
||||
| Trial expiry | `internal/workflows/entitlements/grant_expiration_workflow.go` + `activities.go` | Scheduled Temporal workflow → `Transition(End)` on `valid_until` |
|
||||
| Operator UI | `internal/server/operator_plan_ladders.go` | Ladder + tier CRUD; structural-invariant validation view |
|
||||
| Operator UI | `internal/server/operator_org_types.go` | Default-plan-ladder change flow: select → server-side preview (population classification) → commit with grandfather/migrate dispositions |
|
||||
| Operator UI | `internal/server/operator_enrollment.go` | Per-org view; force-transition; trial grant; revoke |
|
||||
| Member UI | `internal/server/member_products.go` + `internal/embeds/templates/partials/member_entitlements.html` | Read-only "you are on `<tier>`" extension |
|
||||
|
||||
### Grant lineage
|
||||
|
||||
When a grant supersedes a prior one (extension, comp cycle, sponsored renewal, salvage on cancellation), the new grant's `core.grants.extends_grant_id` column points back to the previous grant. The chain lives at the grant layer, not at the attachment layer: `pool_provision_ladders` row identity cycles with each supersession, but `grants` rows endure. To answer "which grants kept this org on tier X from date A to date B," walk the chain via `entitlements.GetGrantLineage(focal_grant_id)` (recursive CTE, ancestors-in-walk-order).
|
||||
|
||||
Same-org and immutability invariants are enforced by triggers in the core migration baseline (`internal/db/migrations/00001_init.sql`). The column is set only at grant creation; UPDATEs that change `extends_grant_id` are rejected. Cross-org chains are rejected. Self-reference is rejected by CHECK.
|
||||
|
||||
## How to add new plan functionality
|
||||
|
||||
Before writing code, classify your change. Pick the row that matches and follow the rule.
|
||||
|
||||
| You're adding… | Belongs in | Rule |
|
||||
|--------------------------------------------------------|----------------------------------------------------------------------------------|------|
|
||||
| A new way to **mutate** tier position | Call `entitlements.Transition` from wherever the trigger lives. | Never write `pool_provisions.status`, `pool_provision_ladders`, or `pool_provision_transitions` directly. |
|
||||
| A new transition **source** (e.g., GitHub Sponsors) | Add a call site that invokes `Transition` with its own `actor_type` value. | The classification + audit logic stays inside `Transition`. |
|
||||
| A new **catalog** concept (ladder shape, tier prop) | `internal/billing/queries/plan_ladders.sql` + sqlc regen. | Catalog is billing's domain. |
|
||||
| A new **operator action** on enrollment | New handler in `internal/server/operator_enrollment.go`. | Handler must call `Transition`, not touch DB directly. |
|
||||
| A new **member-facing** tier view | `internal/server/member_products.go` + a partial template. | Member UI is read-only for plan state. |
|
||||
| A new **async** trigger for transitions | New Temporal activity that calls `Transition` (mirror `grant_expiration_workflow.go`). | Activities open their own tx, call `Transition`, commit. |
|
||||
| Validation of **structural invariants** | `internal/server/operator_plan_ladders.go` → `GetPlanLadderValidation` view. | Surface violations; do not silently correct. |
|
||||
| Extending an org on its **current tier** | Call `Transition` with `TransitionTarget{Extend: true}` (see `operator_enrollment.go:ExtendGrant`). | Extend issues a new grant + provision at the same tier and records `transition_type='extend'`. |
|
||||
|
||||
If your change needs to mutate `pool_provisions`-related tables **outside** `Transition`, that is a signal the primitive is missing a capability — extend `Transition` rather than bypassing it.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't** extract `internal/plan/` as a coordinator package. The sole-writer primitive already concentrates the invariants. A coordinator would move code without reducing surface area, and would have to import both `billing` and `entitlements`, adding a dependency layer.
|
||||
- **Don't** write to `pool_provisions.status` directly except for `customer.subscription.updated` intermediate states (`past_due`, `unpaid`, `trialing`) — those are explicitly not ladder transitions (per Decision 5 in the M6 design).
|
||||
- **Don't** create a second path to issue plan-product grants. Route operator-initiated grants on plan products through the Enrollment tab, which calls `Transition`; the general Grants tab is for non-plan products only (see `grant-plan-safety.md` gap #1).
|
||||
- **Don't** attempt to keep `core.subscription_changes` and `core.pool_provision_transitions` in sync by hand. Both get written independently by their respective writers; `subscription_changes` is Stripe-side canonical, `pool_provision_transitions` is enrollment-side canonical.
|
||||
|
||||
## Related documents
|
||||
|
||||
- `docs/grant-plan-safety.md` — incident-memory log for plan-related safety issues (gaps #1–#3 closed by `plan-sole-writer-guards`).
|
||||
- `docs/plan-management.md` — operator-facing guide to plan configuration and transition workflows.
|
||||
- `openspec/changes/plan-management-foundation/design.md` — design decisions log (end-and-re-apply, NULL-default contract, single-primitive rationale).
|
||||
- `openspec/changes/plan-management-foundation/specs/plan-transitions/spec.md` — spec for the transition capability (treated as a distinct capability at the spec layer even though code is distributed).
|
||||
@@ -8,7 +8,7 @@ summary: "An operator's guide to configuring plan ladders, per-org-type default
|
||||
|
||||
This document describes how operators configure plan ladders, manage automatic provisioning, perform manual transitions, and audit enrollment history.
|
||||
|
||||
> Developers: see [`plan-architecture.md`](./plan-architecture.md) for where plan code lives and the sole-writer rule.
|
||||
> For how the plan ladder model works underneath these procedures (the object graph, the conferral functions, and the invariants that govern position changes), see the [Plan Ladders and Transitions](models/plan-ladders-transitions.md) card.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -32,7 +32,7 @@ Use a **regular product** (addon, usage, one-time) when:
|
||||
|
||||
If you find yourself wanting to grant the same plan twice to the same org, consider whether you need an **extend** (same tier, new grant) or whether the product should have been modeled as an addon. Plans enforce one-tier-per-ladder by design; addons and usage products do not. The **Extend Current Tier** action exists precisely for the legitimate case of issuing another grant on the same tier.
|
||||
|
||||
> Developers: this distinction is enforced at the catalog layer (membership in `plan_ladder_tiers` is opt-in per product). See `plan-architecture.md` for the runtime consequence.
|
||||
> Developers: this distinction is enforced at the catalog layer (membership in `plan_ladder_tiers` is opt-in per product). See the [Plan Ladders and Transitions](models/plan-ladders-transitions.md) card for the runtime consequence: conferral only creates ladder-occupancy rows when the conferred product is a tier on a ladder.
|
||||
|
||||
## Making a plan available for purchase
|
||||
|
||||
@@ -131,7 +131,7 @@ An operator can issue a grant to move a pool to a specific tier:
|
||||
|
||||
The system:
|
||||
- Creates a grant with `grant_reason = 'manual'` (or `'trial'` if `valid_until` is set)
|
||||
- Invokes `Transition` to attach the pool to the target tier
|
||||
- Calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements
|
||||
- Registers a **Temporal workflow** to expire the grant at `valid_until` **only** for trials
|
||||
|
||||
A grant with no `valid_until` is permanent. A grant with `valid_until` is a time-bounded trial; when it expires, the pool automatically transitions back to the organization's default tier (or detaches if no default is configured).
|
||||
@@ -144,7 +144,7 @@ An operator can revoke an active grant:
|
||||
2. Click **Revoke**.
|
||||
3. Confirm the action.
|
||||
|
||||
The system revokes the grant, ends the associated provision, and invokes `Transition(End)` to return the pool to its default tier.
|
||||
The system revokes the grant, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: the handler ends the position first, then separately calls the guarded default restoration, which returns the pool to the organization's default tier only when the pool is left with no live position on any ladder.
|
||||
|
||||
### Extend Current Tier
|
||||
|
||||
@@ -157,15 +157,15 @@ An operator can issue a new grant on the pool's current tier without changing po
|
||||
|
||||
The system:
|
||||
- Creates a new grant chained to the prior grant via `extends_grant_id`
|
||||
- Invokes `Transition` with `Extend=true` to create a new provision + ladder attachment at the same tier
|
||||
- Records a `transition_type='extend'` audit row with `from_rank == to_rank`
|
||||
- Calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: because the new grant declares that it extends the prior grant, conferral recognizes the same source continuing and creates a new provision and ladder attachment at the same tier, taking over the position from the prior grant
|
||||
- Records the change as a `transfer` audit row, not an `extend` row (no `extend` transition type exists); the row's `from_rank` equals its `to_rank` because the tier does not change
|
||||
- Schedules `GrantExpirationWorkflow` keyed off the new grant's ID when `Expires at` is set, so trial extensions expire on their own clock and don't ride the original trial's expiry
|
||||
|
||||
Use this to extend a trial that is about to expire (set a new `Expires at`), comp an additional cycle during an outage, or gift a second period without disturbing the existing audit trail.
|
||||
|
||||
## Downgrades and the tier-reduction policy
|
||||
|
||||
A **downgrade** returns a pool to a lower tier — triggered by a Stripe subscription cancellation (reconciled to `Transition(End)`), an operator **Revoke**, or a trial grant expiring. In every case the pool is returned to its organization-type default via `ReapplyDefaultsForPool`: if a default plan ladder is configured the pool is re-provisioned at that ladder's rank-0 product (a `downgrade` transition is recorded); if none is configured the pool legitimately goes off-ladder (an `end` transition is recorded).
|
||||
A pool moves to a lower tier, or off the ladder entirely, in three cases: a Stripe subscription cancellation (reconciled by `internal/fulfillment/reconcile.go`, which calls the conferral boundary directly to end the position), an operator **Revoke**, or a trial grant expiring. In every case, ending the old position and restoring the default are two separate steps, because ending never re-mints the default by itself: each entry point ends the position first, then separately calls the guarded default restoration (`ReapplyDefaultsIfVacant` in `internal/entitlements/reapply_defaults.go`), which checks whether the pool is left with no live position on any ladder before acting. When the organization type has a default plan ladder configured and the pool is vacant, the restoration confers that ladder's rank-0 product and records the new position as an `initiate`, not a downgrade, because it is a fresh position rather than a step down from the one that just ended. When no default is configured, the pool legitimately goes off-ladder and the restoration records nothing further.
|
||||
|
||||
### What happens to resources already in use
|
||||
|
||||
@@ -183,8 +183,8 @@ When the new tier's limit is **below** the member's current usage (e.g. Standard
|
||||
|
||||
The enrollment page displays a chronological list of all `pool_provision_transitions` rows for each pool. Each row shows:
|
||||
|
||||
- **Type** — `initiate`, `upgrade`, `downgrade`, `end`, or `extend`
|
||||
- **From / To rank** — the ladder position delta (`extend` shows equal ranks)
|
||||
- **Type** — `initiate`, `upgrade`, `downgrade`, `end`, or `transfer`
|
||||
- **From / To rank** — the ladder position delta (`transfer` shows equal ranks, because a transfer moves the position to a new source at the same tier)
|
||||
- **Actor** — `system`, `webhook` (Stripe), or the operator's display name
|
||||
- **Reason** — free-form text or system-generated attribution
|
||||
- **Effective at** — when the change took effect
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Subscribe to these event types:
|
||||
|
||||
The member console is the catalog source of truth; Stripe holds a mirror used for payment execution. Catalog objects do **not** sync to Stripe automatically — syncing a product to Stripe is an **explicit operator action** (see "Making a product purchasable" below). Once synced, members subscribe via Stripe Checkout, Stripe runs the billing cycle, and webhooks project Stripe state (subscriptions, invoices, payments) back into the member console's core tables.
|
||||
|
||||
Sync runs through a transactional outbox: the operator action enqueues `create_stripe_product` and `create_stripe_price` entries, a worker drains them against the Stripe API, and the resulting `stripe.product_mappings` / `stripe.price_mappings` rows record the mapping and its `sync_status` (`pending` → `synced`, or `dead_letter` on repeated failure).
|
||||
Sync runs through a transactional outbox: the operator action enqueues `create_stripe_product` and `create_stripe_price` entries, and a worker drains them against the Stripe API. The resulting `stripe.product_mappings` / `stripe.price_mappings` rows record the mapping and its `sync_status`, which moves from `pending` to `synced` (or to `deleted`). `dead_letter` is a status on the outbox entry itself, not on the mapping: when an entry fails repeatedly and the outbox marks it `dead_letter`, the mapping it was trying to create stays at `pending` until an operator retries the entry and the retry succeeds.
|
||||
|
||||
## Making a product purchasable
|
||||
|
||||
|
||||
@@ -78,26 +78,6 @@ func (q *Queries) DeletePlacement(ctx context.Context, placementID string) (int6
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deletePlacementByResource = `-- name: DeletePlacementByResource :execrows
|
||||
DELETE FROM domains.placements
|
||||
WHERE provider = $1 AND resource_ref = $2
|
||||
`
|
||||
|
||||
type DeletePlacementByResourceParams struct {
|
||||
Provider string `json:"provider"`
|
||||
ResourceRef string `json:"resource_ref"`
|
||||
}
|
||||
|
||||
// Release path: placements are hard-deleted (the registry stores current
|
||||
// state, not history).
|
||||
func (q *Queries) DeletePlacementByResource(ctx context.Context, arg DeletePlacementByResourceParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deletePlacementByResource, arg.Provider, arg.ResourceRef)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getPlacementByFQDN = `-- name: GetPlacementByFQDN :one
|
||||
SELECT placement_id, claim_id, fqdn, reversed_labels, provider, resource_ref, servable, created_at, updated_at FROM domains.placements
|
||||
WHERE fqdn = $1
|
||||
@@ -295,25 +275,6 @@ func (q *Queries) ListPlacementsInSubtree(ctx context.Context, reversedRoot stri
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setPlacementServable = `-- name: SetPlacementServable :execrows
|
||||
UPDATE domains.placements
|
||||
SET servable = $2, updated_at = NOW()
|
||||
WHERE placement_id = $1
|
||||
`
|
||||
|
||||
type SetPlacementServableParams struct {
|
||||
PlacementID string `json:"placement_id"`
|
||||
Servable bool `json:"servable"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetPlacementServable(ctx context.Context, arg SetPlacementServableParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setPlacementServable, arg.PlacementID, arg.Servable)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setPlacementServableByResource = `-- name: SetPlacementServableByResource :execrows
|
||||
UPDATE domains.placements
|
||||
SET servable = $3, updated_at = NOW()
|
||||
|
||||
@@ -23,9 +23,6 @@ type Querier interface {
|
||||
CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim, error)
|
||||
CreatePlacement(ctx context.Context, arg CreatePlacementParams) (Placement, error)
|
||||
DeletePlacement(ctx context.Context, placementID string) (int64, error)
|
||||
// Release path: placements are hard-deleted (the registry stores current
|
||||
// state, not history).
|
||||
DeletePlacementByResource(ctx context.Context, arg DeletePlacementByResourceParams) (int64, error)
|
||||
GetClaimByID(ctx context.Context, claimID string) (Claim, error)
|
||||
// A pending or active row holds the name (the partial unique index enforces
|
||||
// at most one); terminal rows do not block re-claiming.
|
||||
@@ -202,7 +199,6 @@ type Querier interface {
|
||||
// there. COALESCE makes the latch sticky and the write idempotent, so a lost
|
||||
// write is recovered by the next probe.
|
||||
RecordClaimProbe(ctx context.Context, arg RecordClaimProbeParams) (int64, error)
|
||||
SetPlacementServable(ctx context.Context, arg SetPlacementServableParams) (int64, error)
|
||||
// Provider lifecycle transitions address placements by their own resource
|
||||
// identity, not by name (fedwiki: archive => FALSE, restore => TRUE).
|
||||
SetPlacementServableByResource(ctx context.Context, arg SetPlacementServableByResourceParams) (int64, error)
|
||||
|
||||
@@ -46,11 +46,6 @@ SELECT * FROM domains.placements
|
||||
WHERE reversed_labels LIKE @reversed_root::text || '.%'
|
||||
ORDER BY reversed_labels;
|
||||
|
||||
-- name: SetPlacementServable :execrows
|
||||
UPDATE domains.placements
|
||||
SET servable = $2, updated_at = NOW()
|
||||
WHERE placement_id = $1;
|
||||
|
||||
-- name: SetPlacementServableByResource :execrows
|
||||
-- Provider lifecycle transitions address placements by their own resource
|
||||
-- identity, not by name (fedwiki: archive => FALSE, restore => TRUE).
|
||||
@@ -61,9 +56,3 @@ WHERE provider = $1 AND resource_ref = $2;
|
||||
-- name: DeletePlacement :execrows
|
||||
DELETE FROM domains.placements
|
||||
WHERE placement_id = $1;
|
||||
|
||||
-- name: DeletePlacementByResource :execrows
|
||||
-- Release path: placements are hard-deleted (the registry stores current
|
||||
-- state, not history).
|
||||
DELETE FROM domains.placements
|
||||
WHERE provider = $1 AND resource_ref = $2;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
// - internal/provisioning/provisioning.go — default grant on org creation
|
||||
// - internal/demoseed/ — seeded grants
|
||||
//
|
||||
// See docs/plan-architecture.md for the full architecture and
|
||||
// docs/grant-plan-safety.md for the historical incident log.
|
||||
// See docs/models/plan-ladders-transitions.md for the full model description
|
||||
// and status/archive/incident-2026-04-grant-provision-sync.md for the
|
||||
// incident that motivated the sole-writer protections.
|
||||
package entitlements
|
||||
|
||||
@@ -24,4 +24,6 @@ type Provider struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SuspendedAt sql.NullTime `json:"suspended_at"`
|
||||
RetiredAt sql.NullTime `json:"retired_at"`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const getProvider = `-- name: GetProvider :one
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at FROM core.providers WHERE slug = $1
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at, suspended_at, retired_at FROM core.providers WHERE slug = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetProvider(ctx context.Context, slug string) (Provider, error) {
|
||||
@@ -25,6 +25,8 @@ func (q *Queries) GetProvider(ctx context.Context, slug string) (Provider, error
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SuspendedAt,
|
||||
&i.RetiredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -58,7 +60,7 @@ func (q *Queries) ListPlatformResourceKeys(ctx context.Context) ([]string, error
|
||||
}
|
||||
|
||||
const listProviders = `-- name: ListProviders :many
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at FROM core.providers ORDER BY slug
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at, suspended_at, retired_at FROM core.providers ORDER BY slug
|
||||
`
|
||||
|
||||
func (q *Queries) ListProviders(ctx context.Context) ([]Provider, error) {
|
||||
@@ -78,6 +80,8 @@ func (q *Queries) ListProviders(ctx context.Context) ([]Provider, error) {
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SuspendedAt,
|
||||
&i.RetiredAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +97,7 @@ func (q *Queries) ListProviders(ctx context.Context) ([]Provider, error) {
|
||||
}
|
||||
|
||||
const listProvidersByKind = `-- name: ListProvidersByKind :many
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at FROM core.providers WHERE provider_kind = $1 ORDER BY slug
|
||||
SELECT slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at, suspended_at, retired_at FROM core.providers WHERE provider_kind = $1 ORDER BY slug
|
||||
`
|
||||
|
||||
func (q *Queries) ListProvidersByKind(ctx context.Context, providerKind string) ([]Provider, error) {
|
||||
@@ -113,6 +117,8 @@ func (q *Queries) ListProvidersByKind(ctx context.Context, providerKind string)
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SuspendedAt,
|
||||
&i.RetiredAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,7 +177,7 @@ SET provider_kind = EXCLUDED.provider_kind,
|
||||
display_name = EXCLUDED.display_name,
|
||||
operator_surface_path = EXCLUDED.operator_surface_path,
|
||||
updated_at = NOW()
|
||||
RETURNING slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at
|
||||
RETURNING slug, provider_kind, display_name, operator_surface_path, status, created_at, updated_at, suspended_at, retired_at
|
||||
`
|
||||
|
||||
type UpsertProviderParams struct {
|
||||
@@ -199,6 +205,8 @@ func (q *Queries) UpsertProvider(ctx context.Context, arg UpsertProviderParams)
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SuspendedAt,
|
||||
&i.RetiredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -205,22 +205,32 @@ func backfillSitePlacements(
|
||||
// registry transaction so the claim it may have to create and the placement
|
||||
// that justifies creating it commit together.
|
||||
//
|
||||
// The classification is the delta spec's, exactly:
|
||||
// The classification switches on the deepest live claim covering the site's
|
||||
// domain and the row's workspace, in this order (seven branches):
|
||||
//
|
||||
// - inside an operator root, System-workspace row → operator placement
|
||||
// directly in the root (infrastructure names and sync projections);
|
||||
// - inside an operator root, member-workspace row → carve the member claim
|
||||
// and place at its apex;
|
||||
// - inside a live member/external claim owned by the SAME workspace → a
|
||||
// placement alone (this is how nested pre-registry sites join the claim
|
||||
// the shorter name already created);
|
||||
// - inside a live claim owned by ANOTHER workspace → left unplaced and
|
||||
// logged: adopting it would hand one member's name to another;
|
||||
// - outside every live claim, member workspace → an `active` external claim,
|
||||
// grandfathered (control was proven under the flow that created the site);
|
||||
// - outside every live claim, System workspace → left unplaced. Those are
|
||||
// sync projections of foreign farm names the console never authorized;
|
||||
// placing them would silently grandfather unverified names into serving.
|
||||
// - no covering claim, System-workspace row → left unplaced and logged.
|
||||
// Sync's own contract leaves foreign farm-name projections unplaced;
|
||||
// re-placing them here would undo that decision from the other side;
|
||||
// - no covering claim, member-workspace row → an `active` external claim,
|
||||
// grandfathered via GrandfatherExternalClaim (control was proven under
|
||||
// the flow that created the site);
|
||||
// - claim is an operator root, System-workspace row → an operator
|
||||
// placement directly in the root (infrastructure names and sync
|
||||
// projections);
|
||||
// - claim is an operator root, member-workspace row → grandfathered via
|
||||
// GrandfatherCarvedClaim, NOT an ordinary carve: that call deliberately
|
||||
// bypasses the allocation-time name-policy gate (design D6), because
|
||||
// this site already exists — running the gate here would let a policy
|
||||
// rule seeded after the fact refuse adoption on every boot and keep a
|
||||
// member's live site off the air. The namespace invariants still apply;
|
||||
// - claim is live but owned by ANOTHER workspace → left unplaced and
|
||||
// logged: only the claim owner may place there;
|
||||
// - claim is owned by the SAME workspace but not yet `active` → left
|
||||
// unplaced: the workspace's own claim is still pending verification,
|
||||
// and its own activation is what unblocks this row on a later boot;
|
||||
// - claim is `active` and owned by the SAME workspace → a placement alone
|
||||
// (this is how nested pre-registry sites join the claim the shorter
|
||||
// name already created).
|
||||
//
|
||||
// Servability is seeded from the site's own status (active/readonly serve,
|
||||
// archived does not), matching what the lifecycle paths maintain afterwards.
|
||||
|
||||
@@ -1,51 +1,13 @@
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
|
||||
)
|
||||
|
||||
// EnsureStripeCustomerPayload is the outbox payload for create_stripe_customer actions.
|
||||
// EnsureStripeCustomerPayload is the outbox payload for create_stripe_customer
|
||||
// actions. Checkout creates Stripe customers synchronously, so nothing in
|
||||
// this package enqueues one anymore — but the outbox worker's dispatch table
|
||||
// still unmarshals this shape (workflows/outbox.go's executeCreateStripeCustomer),
|
||||
// so keep it even if its former producer (EnsureStripeCustomer) is gone.
|
||||
type EnsureStripeCustomerPayload struct {
|
||||
BillingAccountID string `json:"billing_account_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
BillingAccountName string `json:"billing_account_name"`
|
||||
}
|
||||
|
||||
// EnsureStripeCustomer checks for an existing customer mapping and, if none
|
||||
// exists, inserts a create_stripe_customer outbox entry within the caller's
|
||||
// transaction. Returns the existing stripe_customer_id if the mapping is
|
||||
// synced, or an empty string if a creation was enqueued.
|
||||
func EnsureStripeCustomer(ctx context.Context, tx *sql.Tx, billingAccountID, orgID, orgName, billingAccountName string) (string, error) {
|
||||
q := New(tx)
|
||||
|
||||
mapping, err := q.GetCustomerMappingByBillingAccountID(ctx, billingAccountID)
|
||||
if err == nil {
|
||||
// Mapping exists
|
||||
if mapping.SyncStatus == "synced" && mapping.StripeCustomerID.Valid {
|
||||
return mapping.StripeCustomerID.String, nil
|
||||
}
|
||||
// Mapping exists but pending/failed — don't enqueue again
|
||||
return "", nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return "", fmt.Errorf("check customer mapping: %w", err)
|
||||
}
|
||||
|
||||
// No mapping — enqueue outbox entry
|
||||
payload := EnsureStripeCustomerPayload{
|
||||
BillingAccountID: billingAccountID,
|
||||
OrgID: orgID,
|
||||
OrgName: orgName,
|
||||
BillingAccountName: billingAccountName,
|
||||
}
|
||||
if err := integration.Enqueue(ctx, tx, "stripe", "create_stripe_customer", payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -95,54 +95,6 @@ func createTestBillingAccount(t *testing.T, ctx context.Context, tx *sql.Tx, org
|
||||
return ba.BillingAccountID
|
||||
}
|
||||
|
||||
func TestEnsureStripeCustomer_EnqueuesOutboxEntry(t *testing.T) {
|
||||
database := testDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
orgID, orgName := createTestOrg(t, ctx, tx)
|
||||
baID := createTestBillingAccount(t, ctx, tx, orgID)
|
||||
|
||||
// Call EnsureStripeCustomer — should enqueue an outbox entry
|
||||
stripeID, err := internalstripe.EnsureStripeCustomer(ctx, tx, baID, orgID, orgName, "Test Billing Account")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureStripeCustomer: %v", err)
|
||||
}
|
||||
if stripeID != "" {
|
||||
t.Fatalf("expected empty stripe_customer_id, got %q", stripeID)
|
||||
}
|
||||
|
||||
// Verify outbox entry was created
|
||||
var actionType string
|
||||
var payload json.RawMessage
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT action_type, payload FROM core.outbox
|
||||
WHERE provider = 'stripe' ORDER BY id DESC LIMIT 1`,
|
||||
).Scan(&actionType, &payload)
|
||||
if err != nil {
|
||||
t.Fatalf("read outbox entry: %v", err)
|
||||
}
|
||||
if actionType != "create_stripe_customer" {
|
||||
t.Fatalf("expected action_type create_stripe_customer, got %q", actionType)
|
||||
}
|
||||
|
||||
var p internalstripe.EnsureStripeCustomerPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if p.BillingAccountID != baID {
|
||||
t.Fatalf("payload billing_account_id = %q, want %q", p.BillingAccountID, baID)
|
||||
}
|
||||
if p.OrgID != orgID {
|
||||
t.Fatalf("payload org_id = %q, want %q", p.OrgID, orgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxExecutor_CreateStripeCustomer(t *testing.T) {
|
||||
database := testDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -84,9 +84,10 @@ type EntitlementSetDetailData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// entitlementSetNameConstraints maps the name-uniqueness constraint (added in
|
||||
// migration 00012, finding #40) to a friendly field error so a duplicate name
|
||||
// renders on the "name" input instead of leaking a 23505 into the banner.
|
||||
// entitlementSetNameConstraints maps the name-uniqueness constraint (finding
|
||||
// #40; baked into the 00001_init.sql baseline, not a later migration) to a
|
||||
// friendly field error so a duplicate name renders on the "name" input
|
||||
// instead of leaking a 23505 into the banner.
|
||||
var entitlementSetNameConstraints = web.ConstraintMessages{
|
||||
"uq_entitlement_sets_name": {Field: "name", Message: "That name is already in use. Choose a different name."},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-22
|
||||
@@ -0,0 +1,35 @@
|
||||
# Design — model-doc-rot
|
||||
|
||||
## Context
|
||||
|
||||
The 10i verification passes ledgered a batch of documentation that describes deleted machinery (`status/issues.md`, model-documentation ledgers). A survey (2026-08-22) established per-file verdicts with line-level evidence. The deleted `Transition` primitive is the common thread: doc-41 replaced it with five conferral functions, and `docs/models/plan-ladders-transitions.md` is now the verified description of that model. Decision 141 makes the cards the single descriptive home, which decides retire-vs-rewrite: description belongs in cards; how-to belongs in docs.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** no launch-facing doc, comment, or Makefile target teaches deleted machinery; redundant model description is retired rather than repaired; genuine how-to content survives with corrected verbs; dead code stops misleading readers about the write path.
|
||||
|
||||
**Non-Goals:** no new documentation beyond the incident-history note; no behavior changes anywhere; no card content changes (the cards are already correct); not the stacking exploration or any other open issue.
|
||||
|
||||
## Decisions
|
||||
|
||||
**D1 — Retire, don't rewrite, the two redundant docs.** `docs/plan-architecture.md`: near-zero survivable content (survey: every section either duplicates the plan-ladders card or names `Transition`/`transitions.go`/dead paths). `docs/grant-plan-safety.md`: its GiST-constraint and sync-trigger material duplicates card invariants 1 and 9, and its "provision invisible to the sync trigger" hazard describes a pre-conferral world that invariant 2 (sole-writer functions) makes unrepresentable. Rewriting either would recreate a second descriptive home for a carded model — the exact redundancy 10i existed to end.
|
||||
*Alternative rejected:* trimming them to stubs that link to the cards — a stub with a front-matter and one link is index noise; `docs/README.md` already links the card catalog.
|
||||
|
||||
**D2 — The incident narrative is preserved as history, in status.** `grant-plan-safety.md:17-27` records the 2026-04 incident (app code created a provision row invisible to the ladder-sync trigger) that motivated today's protections. Cards carry no history by rule, and git history is preservation-by-accident. It moves to `status/archive/incident-2026-04-grant-provision-sync.md`, three short paragraphs: what happened, what it revealed, which card invariants now make it unrepresentable (plan-ladders invariants 1, 2, 9).
|
||||
|
||||
**D3 — `plan-management.md` is corrected in place, delegating description to the card.** The operator workflows (~90% of the file) survive. Corrections: every "invokes `Transition`" sentence re-pointed to the conferral boundary (`internal/entitlements/conferral.go` and the operator handlers); the audit-row vocabulary fixed to `initiate`/`upgrade`/`downgrade`/`end`/`transfer` (no `extend` — extension is extend-as-replace, recorded through lineage, and default restoration records `initiate`, per card invariant 10); the intro gains one sentence delegating model description to the card. Prose follows the concrete-prose rules (`docs/models/README.md`, Style contract).
|
||||
|
||||
**D4 — `EnsureStripeCustomer` dies; its payload struct lives.** The function has no production caller (checkout creates Stripe customers synchronously — a carded fact), but `EnsureStripeCustomerPayload` is unmarshaled by the outbox worker (`workflows/outbox.go:205`), so only the function and its test coverage go; the struct stays where the outbox can reach it, with a comment saying who consumes it.
|
||||
|
||||
**D5 — Comment fixes state the code's actual shape.** `reconcileSite`'s doc comment enumerates the real seven branches (including the non-active-claim skip) and names `GrandfatherCarvedClaim` for operator-root member rows (deliberate name-policy bypass) instead of claiming ordinary carving. The entitlement-sets comment cites the baseline migration instead of the nonexistent 00012.
|
||||
|
||||
**D6 — Makefile target loops the real configs.** `sqlc-generate` iterates the nine directories carrying a `sqlc.yaml` (six core packages + three integration stores), failing on first error, so the target and `docs/database-management.md` agree.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Inbound links to retired docs]** Anything linking `plan-architecture.md` or `grant-plan-safety.md` breaks. → Task includes a repo-wide grep for references; the survey already found only the `docs/README.md` index lines.
|
||||
- **[Deleting queries regenerates sqlc]** The domains regeneration must produce no diff beyond the removed queries. → Verified by build + full domains test run.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Docs-and-dead-code only; a normal rollout with no schema or behavior change. Rollback is `git revert`.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Model doc rot
|
||||
|
||||
## Why
|
||||
|
||||
The 10i model-card verification passes found that several launch-facing documents describe machinery that no longer exists — most prominently the deleted `Transition` primitive, which three docs still teach as the way plan changes work — and that a handful of comments, one Makefile target, and three dead code paths mislead readers the same way. This is the last 10j change: a newcomer following these docs today would look for files that are gone and call functions that were deleted.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Retire `docs/plan-architecture.md`.** It is model description, and the plan-ladders model card now covers everything in it (object graph, sole-writer rule, code locations) with verified claims; what remains references only the deleted `Transition` primitive. The docs index drops it. This enacts the single-descriptive-home rule (Decision 141): a second developer doc re-describing a carded model is redundancy, not coverage.
|
||||
- **Retire `docs/grant-plan-safety.md`, preserving its incident history.** Its safety invariants are now card invariants with enforcement tags; its code references are all dead paths. The one thing the card deliberately cannot hold — the 2026-04 incident narrative that motivated the protections — moves to `status/archive/` as a dated incident note.
|
||||
- **Rewrite `docs/plan-management.md` in place.** It is a genuine operator guide (~90% survives); every reference to `Transition` re-points at the conferral boundary, and the transition-type vocabulary is corrected (`initiate`/`upgrade`/`downgrade`/`end`/`transfer` — no `extend` type exists, and default restoration records `initiate`, not `downgrade`).
|
||||
- **Fix `docs/identity-provider-setup.md`'s role-extraction section.** It still teaches "first non-empty source wins" over three claim locations with a known-limitation callout; the code has merged all four locations as a deduplicated union since the hardening pass.
|
||||
- **Fix `docs/stripe.md`'s sync-status claim.** A catalog mapping's `sync_status` never becomes `dead_letter` (that is an outbox status); mappings go `pending` → `synced` (or `deleted`).
|
||||
- **Delete dead code.** The `EnsureStripeCustomer` function (no production caller; checkout creates customers synchronously) — keeping its payload struct, which the outbox worker still unmarshals; and the unused domains queries `SetPlacementServable` and `DeletePlacementByResource` (the live path uses `SetPlacementServableByResource`).
|
||||
- **Fix two stale comments.** The fedwiki `reconcileSite` doc comment (claims six classification branches "exactly"; the code has seven, plus the grandfather-carve nuance it misstates) and the entitlement-sets comment citing "migration 00012" (the constraint is baked into the `00001` baseline).
|
||||
- **Fix the Makefile `sqlc-generate` target.** It points at a directory with no sqlc config; it becomes a loop over the nine packages that actually carry one, matching `docs/database-management.md`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `model-catalog`: adds the redundancy rule this change enacts — a developer doc that only re-describes a carded model is retired in favor of the card; surviving how-to docs reference cards for model description instead of restating it.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Docs**: two files deleted, one archived note created, three files corrected, `docs/README.md` index updated.
|
||||
- **Code**: one function deleted (struct kept), two sqlc queries deleted (+ regeneration), two comments corrected, one Makefile target fixed. No behavior changes; build/vet/tests must stay green.
|
||||
- **Status**: the doc-rot ledger entries in `status/issues.md` move to resolved; `status/milestones.md` closes 10j.
|
||||
@@ -0,0 +1,17 @@
|
||||
# model-catalog (delta)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cards are the only descriptive home
|
||||
|
||||
A developer-audience document whose content re-describes a carded domain model SHALL be retired in favor of the card, with the docs index updated; it SHALL NOT be maintained in parallel. Documents that survive because they serve a different job (operator how-to, setup guides, runbooks) SHALL reference the relevant card for model description rather than restating it, and SHALL describe only their own procedures. Historical narratives a retired document carried (incidents, motivations) SHALL be preserved under `status/` rather than deleted or moved into a card.
|
||||
|
||||
#### Scenario: A legacy doc duplicates a card
|
||||
|
||||
- **WHEN** a developer doc's content is model description that a card now covers
|
||||
- **THEN** the doc is deleted, the docs index drops it, and any surviving non-descriptive residue (for example an incident history) moves to `status/`
|
||||
|
||||
#### Scenario: A how-to doc needs model background
|
||||
|
||||
- **WHEN** an operator or setup guide would explain how a domain model works to motivate its procedures
|
||||
- **THEN** it links the model's card for that background and keeps only procedure in its own body
|
||||
@@ -0,0 +1,29 @@
|
||||
# Tasks — model-doc-rot
|
||||
|
||||
## 1. Retirements
|
||||
|
||||
- [x] 1.1 Create `status/archive/incident-2026-04-grant-provision-sync.md` from `docs/grant-plan-safety.md:17-27`: what happened (app code created a `pool_provisions` row invisible to the ladder-sync trigger), what it revealed, and which plan-ladders card invariants (1, 2, 9) now make the state unrepresentable. Three short paragraphs, concrete-prose rules.
|
||||
- [x] 1.2 `git rm docs/plan-architecture.md docs/grant-plan-safety.md`; remove their lines from `docs/README.md` (`:52`, `:58`); repo-wide grep for any other reference to either filename and fix what surfaces (survey found only the index lines).
|
||||
|
||||
## 2. Rewrite plan-management.md
|
||||
|
||||
- [x] 2.1 Correct `docs/plan-management.md` in place per design D3: re-point `:134`, `:147`, `:160-161` from `Transition` to the conferral boundary (name the operator handler and `internal/entitlements/conferral.go`, matching how `docs/models/plan-ladders-transitions.md` names the verbs); fix `:186`'s type list to `initiate`/`upgrade`/`downgrade`/`end`/`transfer`; fix `:168` (default restoration records `initiate`, not `downgrade`); add one intro sentence delegating model description to the plan-ladders card. Read the card first and keep terminology identical to it. Concrete-prose rules; no em dashes in any operator-facing copy quoted from the UI.
|
||||
|
||||
## 3. Setup-doc corrections
|
||||
|
||||
- [x] 3.1 `docs/identity-provider-setup.md`: rewrite the role-extraction section (`:39-47`, `:79`) to the union semantics of `extractRoles` (`internal/auth/auth.go:694-713`): all four claim locations merged, deduplicated, order-stable — no "first non-empty wins", and delete the stale known-limitation callout about `resource_access`.
|
||||
- [x] 3.2 `docs/stripe.md:51`: correct the mapping lifecycle to `pending` → `synced` (or `deleted`); state that `dead_letter` is an outbox status and that a terminally failed sync leaves the mapping `pending` until the operator retry re-drives it.
|
||||
|
||||
## 4. Dead code, comments, Makefile
|
||||
|
||||
- [x] 4.1 Delete the `EnsureStripeCustomer` function from `internal/integrations/stripe/store/ensure_customer.go`, keeping `EnsureStripeCustomerPayload` (consumed by `workflows/outbox.go:205`) with a comment naming that consumer; update or remove the function's test usage (`workflows/customer_mapping_test.go:112`).
|
||||
- [x] 4.2 Delete `SetPlacementServable` and `DeletePlacementByResource` from `internal/domains/queries/placements.sql` (`:49`, `:65`); regenerate sqlc for `internal/domains`; confirm the diff removes only those queries and their `querier.go`/generated entries.
|
||||
- [x] 4.3 Fix the `reconcileSite` doc comment (`internal/integrations/fedwiki/reconcile_domains.go:208-224`): enumerate the actual seven branches including the non-active-claim skip (`:306-313`), and name `GrandfatherCarvedClaim` with its deliberate name-policy bypass for operator-root member rows.
|
||||
- [x] 4.4 Fix `internal/server/operator_entitlement_sets.go:87`: the name-uniqueness constraint is baked into the `00001_init.sql` baseline (finding #40's fix was consolidated); drop the 00012 reference.
|
||||
- [x] 4.5 Fix `Makefile:20-21` `sqlc-generate`: loop over the nine directories carrying a `sqlc.yaml` (`internal/{identity,organization,billing,entitlements,integration,domains}`, `internal/integrations/{fedwiki,stripe,discourse}/store`), running `sqlc generate` in each and failing on first error.
|
||||
|
||||
## 5. Verification and bookkeeping
|
||||
|
||||
- [x] 5.1 `go build ./... && go vet ./...` clean; `make sqlc-generate` runs end-to-end with no resulting git diff; full test suite green against the isolated test databases (`./test/reset-test-db.sh`, source `test/.env`, `go test -count=1 -p 1 ./...`).
|
||||
- [x] 5.2 `status/issues.md`: strike RESOLVED (annotated-strike style citing this change) the ledger entries for: the three plan docs teaching the deleted primitive, the identity-provider-setup role-extraction staleness, the stripe.md dead-letter claim, the `EnsureStripeCustomer` dead path, the reconcileSite comment, the 00012 comment (`:532`), and the Makefile `sqlc-generate` target entry.
|
||||
- [x] 5.3 `status/milestones.md`: mark 10j change 3 shipped and the 10j phase Done in its row and the "Remaining for M10" paragraph (critical path becomes 10g → 10d Slice 3 ops → Gitea). Leave commit and archive to the maintainer.
|
||||
@@ -59,3 +59,17 @@ A change that alters an invariant, dimension, or map entry recorded on a card SH
|
||||
#### Scenario: Reviewing new card prose
|
||||
- **WHEN** card prose is added or edited
|
||||
- **THEN** the reviewer checks it against the rules stated in the index, and prose that hides an agent, an object, or a causal relation is rewritten before the change lands
|
||||
|
||||
### Requirement: Cards are the only descriptive home
|
||||
|
||||
A developer-audience document whose content re-describes a carded domain model SHALL be retired in favor of the card, with the docs index updated; it SHALL NOT be maintained in parallel. Documents that survive because they serve a different job (operator how-to, setup guides, runbooks) SHALL reference the relevant card for model description rather than restating it, and SHALL describe only their own procedures. Historical narratives a retired document carried (incidents, motivations) SHALL be preserved under `status/` rather than deleted or moved into a card.
|
||||
|
||||
#### Scenario: A legacy doc duplicates a card
|
||||
|
||||
- **WHEN** a developer doc's content is model description that a card now covers
|
||||
- **THEN** the doc is deleted, the docs index drops it, and any surviving non-descriptive residue (for example an incident history) moves to `status/`
|
||||
|
||||
#### Scenario: A how-to doc needs model background
|
||||
|
||||
- **WHEN** an operator or setup guide would explain how a domain model works to motivate its procedures
|
||||
- **THEN** it links the model's card for that background and keeps only procedure in its own body
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Incident: grant provisions invisible to the ladder-sync trigger (2026-04)
|
||||
|
||||
> Archived 2026-08-22. Moved from `docs/grant-plan-safety.md` (deleted; lines 17-27
|
||||
> carried this narrative) when `docs/plan-architecture.md` and
|
||||
> `docs/grant-plan-safety.md` were retired in favor of the
|
||||
> [Plan Ladders and Transitions](../../docs/models/plan-ladders-transitions.md)
|
||||
> model card, which now carries the invariants this incident motivated.
|
||||
|
||||
In April 2026, Member Console had two separate code paths that could create a
|
||||
grant for an organization: the general Grants tab and the Enrollment tab. The
|
||||
general Grants tab called a function, `CreateGrantAndMaterialize`, that wrote a
|
||||
`pool_provisions` row (recording that a pool now held a product) but never wrote
|
||||
a matching `pool_provision_ladders` row (recording which ladder rung the pool
|
||||
occupied) or a `pool_provision_transitions` audit row. An operator who granted a
|
||||
plan-tier product through the general Grants tab instead of the Enrollment tab
|
||||
therefore left the pool in a state where the database recorded a provision but
|
||||
recorded no ladder position for it.
|
||||
|
||||
This mattered because a database trigger, `trigger_sync_pool_provision_ladders`,
|
||||
propagates a provision's status changes to its ladder rows, but only when a
|
||||
matching ladder row already exists; it does nothing for a provision that has
|
||||
none. So a provision created through the wrong path was invisible to that
|
||||
trigger from the moment it was written, and stayed invisible until someone
|
||||
manually added the missing ladder row. The incident revealed that the sole-writer
|
||||
rule for plan positions was a convention two different call sites could each
|
||||
independently violate, not a rule the database enforced; nothing stopped
|
||||
application code from writing directly to `pool_provisions` while skipping the
|
||||
ladder and audit tables it was supposed to keep in step.
|
||||
|
||||
Three invariants of the current plan-ladders model now make this state
|
||||
impossible to reach again. Invariant 2 restricts all writes to the three
|
||||
position tables (`pool_provisions`, `pool_provision_ladders`,
|
||||
`pool_provision_transitions`) to the five conferral functions, because the
|
||||
application's database role has no insert, update, or delete permission on
|
||||
those tables directly, so no code path can write a provision row without also
|
||||
writing its ladder and audit rows in the same call. Invariant 1 guarantees at
|
||||
most one active occupant per pool per ladder at any instant, because an
|
||||
exclusion constraint rejects any two overlapping active rows for the same pool
|
||||
and ladder, so a pool's ladder position can never go ambiguous or duplicate
|
||||
even if two conferrals race. Invariant 9 guarantees that an ended occupancy
|
||||
episode stays ended, because the status-sync trigger propagates a provision's
|
||||
suspension or recovery to its episode rows but never to rows already marked
|
||||
ended, so recovering a suspended provision can never resurrect a rung that a
|
||||
later supersession already closed out.
|
||||
+11
-11
@@ -407,9 +407,9 @@ After fresh `docker compose up -d`, first `member-console start` fails to reach
|
||||
Labels: `design-feedback`
|
||||
Per-module migrations need a boot sequence that collects from each module's embedded FS in dependency order.
|
||||
|
||||
### Makefile `sqlc-generate` target is stale
|
||||
Labels: `tooling`, `debt`, `low-urgency`
|
||||
The target runs `cd internal/db && sqlc generate`, but `internal/db` has no `sqlc.yaml` — sqlc configs are per-package, and the working command is `cd internal/<package> && sqlc generate` (as `docs/database-management.md` says). Found 2026-08-22 during `purchase-path-blockers`. Fix: make the target iterate the packages that carry a `sqlc.yaml`, or delete it in favor of the documented per-package command (candidate for the 10j `model-doc-rot` batch).
|
||||
### ~~Makefile `sqlc-generate` target is stale~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j)
|
||||
Labels: `tooling`, `resolved`
|
||||
The target now iterates the nine directories that carry a `sqlc.yaml`, failing on first error, matching `docs/database-management.md`. Fixing it immediately paid off: the first run surfaced that `internal/integration`'s generated code had never been regenerated after migration 00010 added `suspended_at`/`retired_at` to `core.providers` (the broken target had hidden the drift); the regeneration is folded into this change, and a second run is a clean no-op. (Found 2026-08-22 during `purchase-path-blockers`.)
|
||||
|
||||
### Shared trigger function ownership
|
||||
Labels: `design-feedback`
|
||||
@@ -502,8 +502,8 @@ From writing `docs/models/payments-billing.md` (code-verified evidence sheet + a
|
||||
- **The no-Stripe-ids-on-core rule has an undocumented exception.** `core.subscription_changes.stripe_event_id` is NOT NULL on a core table, and the reconciler writes attribution strings into it that are not always event ids ("eager:checkout-return", "fire:cancellation"). Either rename it to `attribution`/`reason` or move it provider-side.
|
||||
- **~~`core.subscriptions.status` is bare TEXT with no CHECK~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Migration 00010 adds a CHECK over Stripe's full closed vocabulary (eight values, including `incomplete_expired`, which the reconciler really receives). The designed vocabulary omits `incomplete_expired` — see the Design feedback note filed 2026-08-22.
|
||||
- **Invoice/payment projection trusts the webhook payload.** The "never trust the payload" cardinal rule is scoped to subscriptions; invoice and payment rows are built straight from scrubbed payload fields — a deliberate, carded posture (display facts). **Update 2026-08-22 (`schema-hardening`, 10j): the unguarded int64→int32 casts within that projection are now checked** — out-of-range amounts fail the activity with a non-retryable error naming the event instead of storing a wrapped negative value. En route the executor found and fixed a live bug: `ProcessWebhookEvent` wrapped dispatch errors with `fmt.Errorf`, and the Temporal SDK judges retryability by the top-level error type without unwrapping, so every non-retryable error in the package was silently retryable; now unwrapped via `errors.As` (regression-tested).
|
||||
- **The outbox customer path is dead code.** `EnsureStripeCustomer` (`internal/integrations/stripe/store/ensure_customer.go`), the outbox-based customer-creation helper, has no production caller — checkout creates Stripe customers synchronously (`internal/server/billing.go`) and is the only live path. Either wire the helper into a flow that can tolerate async customer creation or delete it; as-is it misleads readers about the write path.
|
||||
- **`docs/stripe.md` claims mapping `sync_status` can become `dead_letter`** — no code path writes that value to a mapping; dead-letter is an outbox status, and a terminally failed sync leaves the mapping stuck at `pending` until the operator retry re-drives it. Doc fix.
|
||||
- **~~The outbox customer path is dead code~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** `EnsureStripeCustomer` deleted; `EnsureStripeCustomerPayload` kept (the outbox worker still unmarshals it, now commented with its consumer); the test that exercised only the deleted path removed, the live outbox-executor test kept.
|
||||
- **~~`docs/stripe.md` claims mapping `sync_status` can become `dead_letter`~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Doc corrected: mappings go `pending` → `synced` (or `deleted`); dead-letter is an outbox status; a terminally failed sync leaves the mapping `pending` until the operator retry.
|
||||
- **One-pending-scheduled-change has no index backing** — subscription-scoped supersession in the writers only; the design's item-scoped partial unique index doesn't exist. Already tracked under "Subscription scheduled-change + commitment schema implemented from design-only model" (Design feedback); listed here for the card's sake.
|
||||
- **Commitments are read-complete, write-absent** — gating honors the columns; no code, query, or UI sets them, so production is all-evergreen and the block/fee branches are dead paths. Already tracked (commitment fee entry, deferred section).
|
||||
- **`prices.trial_period_days` still stored-but-never-honored** — checkout builds no trial parameters. Already tracked (Deferred-remediation #1).
|
||||
@@ -513,9 +513,9 @@ Labels: `documentation`, `debt`, `plan-transitions`, `bug`
|
||||
|
||||
From writing `docs/models/plan-ladders-transitions.md` (code-verified evidence sheet). The headline is developer-doc rot: three living `docs/` pages describe machinery deleted by the doc-41 change — launch-facing, since these are the docs a new contributor reads:
|
||||
|
||||
- **`docs/plan-architecture.md`, `docs/grant-plan-safety.md`, and `docs/plan-management.md` all describe the dead Go `Transition` primitive.** plan-architecture says "Transition is defined in `internal/entitlements/transitions.go`" (file deleted); grant-plan-safety's reference list names `transitions.go` and `RevokeGrantAndTransition` (neither exists); plan-management says Extend "invokes Transition with Extend=true" recording `transition_type='extend'` ('extend' is CHECK-rejected for new writes; extend records 'transfer' via grant lineage). As-built: the five SECURITY DEFINER conferral functions (`internal/db/migrations/00005_doc41_conferral_functions.sql`) fronted by `internal/entitlements/conferral.go`. All three docs need a doc-41 rewrite pass — M10-relevant.
|
||||
- **`docs/plan-management.md` documents restore-as-downgrade; the code restores-as-initiate.** The doc says cancellation re-provisions rank-0 "(a downgrade transition is recorded)"; every ending path actually ends via `core.end_conferral` then separately calls the floor-guarded restoration, whose confer finds no incumbent and records `initiate`.
|
||||
- **`docs/grant-plan-safety.md`'s wrong-path heuristic is inverted post-doc-41.** It says an active provision with no ladder attachment for a plan-tier product means "the grant was issued through the wrong path"; off-ladder provisions are now legitimate by design, and the real wrong-shape tell is CF002 shape divergence resolved via `core.align_conferral_shape`.
|
||||
- **~~`docs/plan-architecture.md`, `docs/grant-plan-safety.md`, and `docs/plan-management.md` all describe the dead Go `Transition` primitive~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** plan-architecture and grant-plan-safety retired (near-total redundancy with the plan-ladders card; the 2026-04 incident narrative preserved at `status/archive/incident-2026-04-grant-provision-sync.md`; `docs/README.md` index and the `internal/entitlements/doc.go` package comment updated); plan-management corrected in place with its model description delegated to the card. Original entry: plan-architecture says "Transition is defined in `internal/entitlements/transitions.go`" (file deleted); grant-plan-safety's reference list names `transitions.go` and `RevokeGrantAndTransition` (neither exists); plan-management says Extend "invokes Transition with Extend=true" recording `transition_type='extend'` ('extend' is CHECK-rejected for new writes; extend records 'transfer' via grant lineage). As-built: the five SECURITY DEFINER conferral functions (`internal/db/migrations/00005_doc41_conferral_functions.sql`) fronted by `internal/entitlements/conferral.go`. All three docs need a doc-41 rewrite pass — M10-relevant.
|
||||
- **~~`docs/plan-management.md` documents restore-as-downgrade; the code restores-as-initiate~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Corrected in the same plan-management pass: restoration is now documented as recording `initiate` via the floor-guarded reapply path.
|
||||
- **~~`docs/grant-plan-safety.md`'s wrong-path heuristic is inverted post-doc-41~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Resolved by the doc's retirement; the card carries the correct as-built shape story.
|
||||
- **The plan-transitions spec mandates a rank-shape CHECK that doesn't exist.** `openspec/specs/plan-transitions/spec.md` requires a CHECK binding each transition type to its rank pattern; migrations carry only the type-list and occupancy CHECKs — rank shape holds db-code via `core.confer`'s derivation. Either add the (small, NOT VALID) CHECK or amend the spec to accept db-code enforcement.
|
||||
- **Rank contiguity is detection-only.** 0-based contiguous ranks are maintained by UI discipline and reported by the validation page (chosen 2026-07-23, commit `93bf698`); out-of-UI writers can still create gaps. Candidate: deferrable constraint or trigger (nontrivial — reorder's park-then-assign fights a non-deferrable UNIQUE).
|
||||
|
||||
@@ -529,7 +529,7 @@ From writing `docs/models/entitlements.md` (code-verified evidence sheet). Two f
|
||||
- **Person- and billing-account-targeted grants are schema-legal but undeliverable.** The recipient CHECK admits them; every conferral path is org-scoped (`ConferGrantTx` accepts only OrgID; the expiry activity skips pool settlement without one). Constrain the schema or build the delivery path.
|
||||
- **`entitlement_sets.is_active` is an authoring-time gate, not a delivery switch** — inactive sets keep selling, conferring, and materializing; the toggle only trims the product-form picker. Already re-scoped by the 10h de-advertisement (#3, "Show in operator pickers"); listed here because the card states the honest meaning.
|
||||
- **Design tables that were never built:** `credit_grants`, `usage_events`, `pool_ondemand_config`; `quota` and `credit` rule types are authorable per the CHECK but the materializer skips them. Build-or-prune is an upstream question (quota/credit belong with M13 metering).
|
||||
- **Stale code comment:** `operator_entitlement_sets.go` attributes `uq_entitlement_sets_name` to "migration 00012", which no longer exists — the constraint is baked into `00001_init.sql`. One-line comment fix.
|
||||
- **~~Stale code comment:~~ RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The `operator_entitlement_sets.go` comment now cites the `00001_init.sql` baseline, keeping the finding #40 attribution.
|
||||
|
||||
### Stacking policies need a design exploration
|
||||
Labels: `entitlements`, `design`, `product`
|
||||
@@ -574,7 +574,7 @@ From writing `docs/models/domains-registry.md`. Two of the parked notes' traps a
|
||||
- **The schema looks like it constrains what only Go constrains.** `reversed_labels` is plain TEXT with no generated-column or trigger tie to `root_fqdn` (a row written outside the registry silently breaks every subtree scan), and nothing constrains placement-inside-claim containment. Candidate domains migration `00003` if accepted as a design decision.
|
||||
- **Zero means three different things across the config layers.** Operator-set `domains-abandon-budget=0` maps to PolicyDisabled(-1) → ledger off; but a raw `Policy.AbandonBudget == 0` means "unconfigured" → default 3. A composition root passing a literal 0 straight through flips the ledger ON with the default budget. Also the hardening design's non-goal text ("approval-only falls out of budget 0") is backwards as shipped — 0 disables the ledger. Doc fix + consider a typed knob.
|
||||
- **`/domains/ask` 200 is ambiguous while the strangler fallback is configured** — 200 means "servable placement OR the legacy answerer said yes". Migration-window caveat; retires with the fallback (10d Slice 3). Nothing dates that window.
|
||||
- **`reconcileSite`'s doc comment lists six adoption outcomes; the code has seven arms plus an earlier exit** (the pending-own-claim skip is undocumented). One-comment fix.
|
||||
- **~~`reconcileSite`'s doc comment lists six adoption outcomes; the code has seven arms plus an earlier exit~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The comment now enumerates the actual seven branches in code order (including the non-active-claim skip) and names `GrandfatherCarvedClaim` with its deliberate name-policy bypass.
|
||||
- **~~Cancel doesn't probe for evidence~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `MarkClaimCanceled` gained expiry's `@evidence` parameter, and the member cancel path now runs a fresh TXT probe before the write (3 attempts, ~2s each, ~5s budget; probe runs before the registry lock so DNS latency never holds the allocation transaction). Strict on exhaustion per maintainer decision: all-attempts-failed counts as no evidence. Note: the workflow's probe couldn't be imported (cycle); the record format is reimplemented locally with a drift-pin cross-reference, following the ledger test precedent.
|
||||
- **Dead generated surface:** `SetPlacementServable` and `DeletePlacementByResource` have no non-test callers. Cheap cleanup.
|
||||
- **Assumptions to keep visible:** budgets key on workspace_id with no Sybil bound beyond the operator-granted boolean (an operator attaching `external_domain_claims` to a free tier changes the abuse model); the ledger's ScopeLabels is a label count, deliberately PSL-free — the co.uk-style false positive is accepted, not solved. Hosted-label carving is unmetered by design (only the FedWiki numeric quota bounds it), and `name_rules` has no UI — lift the recorded non-goal or declare it seed-only.
|
||||
@@ -586,7 +586,7 @@ Labels: `documentation`, `debt`, `identity`, `bug`
|
||||
From writing `docs/models/identity-organization-workspace.md`. The operational risk leads:
|
||||
|
||||
- **~~Rank-0 fragility can 500 every new-user login~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j, both guards per maintainer decision).** (a) Deleting a ladder's last tier is refused when the ladder is a live org-type default (narrower than the blanket guard the 2026-07-03 remediation rejected — emptying non-default ladders stays legal); (b) `AutoProvision` now completes a plan-less signup with an Error-level alarm naming the ladder when rank 0 is missing, instead of failing the login; the org is repairable via reapply-defaults once the ladder is fixed.
|
||||
- **`docs/identity-provider-setup.md` contradicts the code, stale-safe direction:** it describes first-non-empty-of-three claim extraction and calls `resource_access` unsupported; `extractRoles` unions all four locations (10e). Admin-facing doc — M10-relevant fix.
|
||||
- **~~`docs/identity-provider-setup.md` contradicts the code, stale-safe direction~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The role-extraction section now documents the union of all four claim locations (deduplicated, order-stable) and the stale `resource_access` limitation callout is gone.
|
||||
- **Database role permissions are dormant.** Seeded roles carry rich permission arrays; signup writes memberships and assignments; no server code reads any of it for authorization. Decide (with upstream doc-03) whether to enforce or remove; until then reviewers must not assume they gate anything.
|
||||
- **Returning-login session binding is positional** — first owned org, then its first workspace; multi-org membership has no session story, and `org_members` rows in non-owned orgs never surface. Contract or placeholder? Needs the org-switcher decision before multi-org ships.
|
||||
- **All lifecycle status columns on identity tables are unchecked free text** (users, persons, organizations, workspaces, org_members) — the vocabulary lives only in Go string comparisons. Same class as the subscriptions.status gap in the payments ledger.
|
||||
|
||||
@@ -39,9 +39,9 @@ The "make it presentable" milestone: take the feature-complete engine (M1–M9)
|
||||
| 10g First-contact UX pass | Proposed | Re-walk the whole app from a stranger's seat: progressive disclosure and learn-by-clicking IA so a newcomer can orient without a guide. Prior UX milestones (M3, M7) polished piecemeal; this one takes the newcomer's perspective as the explicit goal. The 2026-07-02 adversarial audit partly pre-empts this — 10h remediates its findings; 10g re-walks after. |
|
||||
| 10h Audit remediation | Done | Execute the triaged 2026-07-02 audit findings ([triage](audit-triage-2026-07-02-triage.md)): 43 quick-fixes, 4 de-advertisements (trial days, Replace stacking, both dead Active toggles — stop implying half-cooked affordances exist), the interim hide of the custom-domain checkbox (real remedy is 10d), and 7 interim guards for structural-defer findings. Blockers first (25). The 7 structural *real* fixes are NOT in scope — they gate on 10i model cards. Verified via build/vet/lint + full DB-backed suite + operator-walkthrough browser e2e against the live stack; two adjacent defects fixed en route (timeout-middleware process crash, test-harness migration numbering — see issues.md). Committed 2026-07-03 (`8514df5` remediation, `fa420ee` org-default fix); same-day follow-up: the ladder Active toggle went from a "not yet enforced" badge to fully hidden, and the 11 postponed items are inventoried as deferred-remediation debt in issues.md. |
|
||||
| 10i Model cards | Done | Write the model catalog ([inventory](model-inventory-2026-07-02.md)): one canonical card per domain model in `docs/models/`, indexed by a `docs/models/README.md` catalog page linked from the docs front door, in the recommended order — product-kind, payments/billing, plan-transitions, entitlements, resource-pools, provider/integration, identity/workspace. Each card pins purpose/invariants/dimensions/edge-cases/open-questions, citing `design/` + membcons-db decision docs. Cards are *descriptive* (code-as-built vs design) and are written in this repo; when a card surfaces an open question that needs a new *normative* decision (e.g. the addon structural signal, payments dimensions), that decision is design work done in membcons-db — the card then updates to cite the resulting decision doc (the doc-35 pattern). Unblocks the 7 deferred structural fixes and de-risks M13. **Done 2026-08-21** (`model-cards` change): eight cards (domains-registry added; "product-kind" renamed product-catalog after Doc 41) + index in `docs/models/`, each code-verified by adversarial fact-check (~53–89 claims/card) and cold-read newcomer tests; per-card drift ledgered under "Model documentation" in [issues.md](issues.md); the run also produced the Doc-39 conformance check ([doc39-conformance-2026-08-21.md](doc39-conformance-2026-08-21.md)) that ratified upstream Decisions 129–133, closing the design record through Decision 141 (data-model v16 synced). Verification surfaced new launch-relevant bugs, triaged into M10 — see the M10 addendum below and the ledgers. |
|
||||
| 10j Verification-found hardening | Proposed | Fix what the 10i verification passes surfaced, sequenced **before 10g** so the first-contact re-walk isn't spent rediscovering known defects (the same ordering that put 10h before 10g). Triaged 2026-08-22 after a per-issue deliberate-vs-oversight audit: nine of the eleven Tier 2 gaps are confirmed oversights, two (rank-0 signup fragility, env enum validation) are *recorded deferrals* whose follow-on was already named — none is a blessed design position. All detail citations live in the issues.md model-documentation ledgers. Three small changes matching the triage tiers: **(1) `purchase-path-blockers` — DONE 2026-08-22** (implemented + verified, uncommitted; archive with the landing commit): member catalog queries filter `lifecycle_status = 'published'` and checkout re-checks the shared member gate before any Stripe call, with a current-rung carve-out so an org enrolled on a now-unpublished tier keeps seeing its plan; the promised `core.webhook_events` partition creator now exists (`internal/db/partitions.go`, boot pass + 24h `webhook-partition-ensure` Temporal schedule, current + 3 months ahead); the Stripe receiver answers 500 when the event insert fails so Stripe redelivers (Discourse's receiver already did). Verified: full DB suite green, live walkthrough (draft hidden from `/products`, direct checkout POST 400s pre-Stripe, publish flip appears; boot created the month-ahead partition and registered the schedule). Cards updated (product-catalog inv 11; payments-billing inv 12/14 + map); three ledger entries resolved. **(2) `schema-hardening` — DONE 2026-08-22** (implemented + verified, uncommitted; archive with the landing commit; maintainer arbitrations 2026-08-22 folded in): migration 00010 carries the schema seatbelts — one-default-pool-per-org and one-primary-per-workspace partial unique indexes (with a dedup pre-flight), `pool_type` CHECK, `core.providers` status CHECK + `suspended_at`/`retired_at` (doc-44 convergence), `core.subscriptions.status` CHECK over Stripe's full eight-value vocabulary (design-feedback note filed: the designed list lacks `incomplete_expired`). App guards: workspace creation via a shared transactional provisioning routine (no more false success); ExtendGrant multi-pool refusal + org/default pool ownership check; rank-0 **both guards** (last-tier delete refused on live org-type defaults; plan-less signup fallback with an error alarm); Slug()/manifest boot assertion; ValidateStart enum enforcement for env-sourced config shared with the override validator; checked int64→int32 conversions in the invoice projection (plus an executor-found live bug fixed: `ProcessWebhookEvent`'s error wrapping silently made every non-retryable Temporal error retryable); domains cancel runs a strict fresh evidence probe with retries before stamping abandonment; stacking authoring restricted to **additive-only** (selector removed; homogeneity/tie-breaker work replaced by the "Stacking policies need a design exploration" issue). Verified: full DB suite + e2e walkthroughs green, migration applied live, all three constraints psql-rejected live, rule form checked in the browser; 12 ledger entries resolved/updated, 2 filed, 7 model cards updated. **(3) `model-doc-rot`** — launch-facing docs that lie: rewrite `docs/plan-architecture.md`, `docs/grant-plan-safety.md`, `docs/plan-management.md` (all still teach the deleted Transition primitive), fix the stale role-extraction section of `docs/identity-provider-setup.md`, plus the one-liner batch (stripe.md dead-letter claim, stale comments, dead-code deletions: `EnsureStripeCustomer` path, unused placement queries). Explicitly deferred beyond M10, staying in the ledgers: rule-change re-materialization fan-out (design work), domains schema constraints and identity status vocabularies (upstream decisions), the ask-200 ambiguity (retires with 10d Slice 3). |
|
||||
| 10j Verification-found hardening | Done | **All three changes shipped 2026-08-22; phase closed.** Fix what the 10i verification passes surfaced, sequenced **before 10g** so the first-contact re-walk isn't spent rediscovering known defects (the same ordering that put 10h before 10g). Triaged 2026-08-22 after a per-issue deliberate-vs-oversight audit: nine of the eleven Tier 2 gaps are confirmed oversights, two (rank-0 signup fragility, env enum validation) are *recorded deferrals* whose follow-on was already named — none is a blessed design position. All detail citations live in the issues.md model-documentation ledgers. Three small changes matching the triage tiers: **(1) `purchase-path-blockers` — DONE 2026-08-22** (implemented + verified, uncommitted; archive with the landing commit): member catalog queries filter `lifecycle_status = 'published'` and checkout re-checks the shared member gate before any Stripe call, with a current-rung carve-out so an org enrolled on a now-unpublished tier keeps seeing its plan; the promised `core.webhook_events` partition creator now exists (`internal/db/partitions.go`, boot pass + 24h `webhook-partition-ensure` Temporal schedule, current + 3 months ahead); the Stripe receiver answers 500 when the event insert fails so Stripe redelivers (Discourse's receiver already did). Verified: full DB suite green, live walkthrough (draft hidden from `/products`, direct checkout POST 400s pre-Stripe, publish flip appears; boot created the month-ahead partition and registered the schedule). Cards updated (product-catalog inv 11; payments-billing inv 12/14 + map); three ledger entries resolved. **(2) `schema-hardening` — DONE 2026-08-22** (implemented + verified, uncommitted; archive with the landing commit; maintainer arbitrations 2026-08-22 folded in): migration 00010 carries the schema seatbelts — one-default-pool-per-org and one-primary-per-workspace partial unique indexes (with a dedup pre-flight), `pool_type` CHECK, `core.providers` status CHECK + `suspended_at`/`retired_at` (doc-44 convergence), `core.subscriptions.status` CHECK over Stripe's full eight-value vocabulary (design-feedback note filed: the designed list lacks `incomplete_expired`). App guards: workspace creation via a shared transactional provisioning routine (no more false success); ExtendGrant multi-pool refusal + org/default pool ownership check; rank-0 **both guards** (last-tier delete refused on live org-type defaults; plan-less signup fallback with an error alarm); Slug()/manifest boot assertion; ValidateStart enum enforcement for env-sourced config shared with the override validator; checked int64→int32 conversions in the invoice projection (plus an executor-found live bug fixed: `ProcessWebhookEvent`'s error wrapping silently made every non-retryable Temporal error retryable); domains cancel runs a strict fresh evidence probe with retries before stamping abandonment; stacking authoring restricted to **additive-only** (selector removed; homogeneity/tie-breaker work replaced by the "Stacking policies need a design exploration" issue). Verified: full DB suite + e2e walkthroughs green, migration applied live, all three constraints psql-rejected live, rule form checked in the browser; 12 ledger entries resolved/updated, 2 filed, 7 model cards updated. **(3) `model-doc-rot` — DONE 2026-08-22** (uncommitted; archive with the landing commit): `plan-architecture.md` and `grant-plan-safety.md` retired rather than rewritten — both were model description now owned by the plan-ladders card, and the retire-don't-duplicate rule is written into the model-catalog spec; the 2026-04 incident narrative preserved at `status/archive/incident-2026-04-grant-provision-sync.md`; `plan-management.md` corrected in place (conferral boundary, real audit vocabulary, restore-records-initiate); `identity-provider-setup.md` role extraction now documents the four-location union; `stripe.md` sync-status claim fixed; dead code deleted (`EnsureStripeCustomer` function with its payload struct kept for the outbox consumer; two unused placement queries); the `reconcileSite` and 00012 comments corrected; the Makefile `sqlc-generate` target now loops the nine real configs — and its first honest run surfaced + folded in the `internal/integration` regeneration that migration 00010 had left behind. Nine ledger entries resolved. Explicitly deferred beyond M10, staying in the ledgers: rule-change re-materialization fan-out (design work), domains schema constraints and identity status vocabularies (upstream decisions), the ask-200 ambiguity (retires with 10d Slice 3). |
|
||||
|
||||
**Remaining for M10 (what's missing):** Done → 10a, 10b, 10c, 10e, 10f, 10h, **10i (2026-08-21)**. Code done, ops pending → 10d (all three code slices shipped; only Slice 3, the adoption/ops cutover, is left). **Not started → 10j, then 10g**: 10j (added by maintainer triage 2026-08-22) fixes what 10i's verification passes surfaced, in three small changes (`purchase-path-blockers` → `schema-hardening` → `model-doc-rot` — see the 10j row); 10g re-walks the app from a stranger's seat after, so the re-walk surfaces new findings instead of known defects. Carried-forward debts, tracked in [issues.md](issues.md): the 10c residual (maintainer live-verify of multi-price/ladder flows) and the 10h deferred-remediation items (4 de-advertisements; the 7 interim guards' card gates settled by 10i). The headline 10j item, maintainer-triaged 2026-08-21: **the draft-product checkout bug** — the member path never checks `lifecycle_status`, so a draft that is active+public+priced+mapped can be bought, with only conferral rejecting it after payment (found by the 10i model-card verification pass; full citations under "Product-catalog card: drift & trap ledger" in [issues.md](issues.md)).
|
||||
**Remaining for M10 (what's missing):** Done → 10a, 10b, 10c, 10e, 10f, 10h, **10i (2026-08-21)**. Code done, ops pending → 10d (all three code slices shipped; only Slice 3, the adoption/ops cutover, is left). **10j Done (2026-08-22)** — all three changes shipped and archived (`purchase-path-blockers`, `schema-hardening`, `model-doc-rot`; see the 10j row). **Not started → 10g**, now the sole code phase left: the first-contact re-walk starts from a codebase whose known defects are fixed and whose docs tell the truth, so it surfaces new findings instead of old ones. Carried-forward debts, tracked in [issues.md](issues.md): the 10c residual (maintainer live-verify of multi-price/ladder flows) and the 10h deferred-remediation items (4 de-advertisements; the 7 interim guards' card gates settled by 10i). The headline 10j item, maintainer-triaged 2026-08-21: **the draft-product checkout bug** — the member path never checks `lifecycle_status`, so a draft that is active+public+priced+mapped can be bought, with only conferral rejecting it after payment (found by the 10i model-card verification pass; full citations under "Product-catalog card: drift & trap ledger" in [issues.md](issues.md)).
|
||||
|
||||
**MVP Checkpoint:** A newcomer can read the README and understand what the member console is in under a minute, run the stack from a documented quickstart without hitting a boot failure that looks like a bug, land on an operator dashboard worth showing off and find their way around it without a guide, complete the operator create-product → price → **sync → sellable** path end-to-end, and (Standard tier) attach a custom domain. The repo carries the standard OSS hygiene files, **the audit's 25 publish-blockers are resolved or mitigated (10h), and every visible affordance does what it says — half-cooked features are hidden, not hinted**. At that point it is presentable to the open-source community.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user