Describe the act record, the obligations and the position copies

The plan acts added structures the model docs did not describe: the act
record and its detail, the reached types and the rule that the newest
act to reach a type owns it, the obligations and their statuses, the
grants' act id, and the two copies on each position (#187).

- docs/models/acts.md is new. It describes the act header and its
  causes, the actor triple, the note and who may read it, the request
  id, the detail tables, the reached types and the ownership function,
  the rows that point at an act, the obligations and how their status
  changes, and which functions alone write these tables. It also says
  that nothing in the record is purged, so an obligation keeps its pool
  and that pool's organization.
- docs/models/plan-ladders-transitions.md: the map rows for ladder
  management and default changes name the acts, the listing, the
  obligations and the drain. Invariant 17 says one builder writes every
  plan act's reason. The last-tier refusal holds for the delete and the
  removal alike.
- docs/models/entitlements.md: core.grants.act_id, and the position's
  org_type and default_sourced copies, with the rule they serve and the
  guard triggers that keep the facts they copy fixed.
- docs/go-conventions.md: the plan acts' runner is the pattern for an
  act that locks several pools, and core.plan_position_is_outgoing and
  core.plan_act_owns_org_type are rules that SQL and Go both read.
- status/design-feedback.md: three entries for the design's data model.
  It has no act record, its transitions use a different actor
  convention from the act's audit triple, and it does not list the
  position's two copies.

Task 9's text now says what the code does in three places: a default
change's reason names the org type, not a ladder; the grants' foreign
key keeps an act, not a pool; and the guard triggers keep five facts
fixed, a provision's pool among them.
This commit is contained in:
2026-10-19 22:12:33 -04:00
parent eeded12c16
commit 9b0e2e5677
7 changed files with 114 additions and 12 deletions
+10 -2
View File
@@ -71,11 +71,19 @@ page that shows a choice and the endpoint that enforces it come to disagree.
must apply it, it lives in a view or a function that the Go code reads, as
`core.superseded_ends` does for the `end` rows a tier change's own row
already states, which the Tier changes list and the activity feed both
leave out.
leave out. `core.plan_position_is_outgoing` states which default-sourced
position is outgoing under a new default, and `core.plan_act_owns_org_type` states
which act owns an organization type; the Go code reads each through the
queries that call it (`GetPlanPoolBucket`, `GetOutgoingDefaultExists`,
`ListPendingPlanActObligations`) and restates neither.
- An act that writes several rows is a function in the package that owns
those tables. It opens its transaction, takes its locks and returns typed
errors; `entitlements.RemoveTier` and `ReorderTiers`, with `PlanActError`,
are the pattern.
are the pattern. An act that locks several pools runs through `runPlanAct`
(`internal/entitlements/plan_act_runner.go`): the act names the pools, the
runner locks them in ascending id order, each once, and the act runs under
those locks. `AddTier` and `RemoveTier` lock the pools of a tier's holders
this way.
- A handler parses the request, calls one such function, maps its errors to a
status and a sentence, and renders. It opens no transaction, takes no lock
and runs no SQL. Some older handlers still do; #221 lists them.
+1
View File
@@ -16,6 +16,7 @@ The catalog was motivated by a 2026-07 inventory of the console's domain models,
- [Payments and Billing](payments-billing.md) — billing accounts, subscriptions, invoices and payments projected from Stripe, scheduled changes as intent, and the reconciler.
- [Plan Ladders and Transitions](plan-ladders-transitions.md) — ranked tiers, who occupies which rung, the append-only audit, and the conferral functions as sole writers.
- [Entitlements](entitlements.md) — resource keys, sets and rules, grants as decrees, provisions as delivery, and the materializer.
- [Acts](acts.md) — the act record for actions that write several rows, the plan acts' details, the obligations a default change or reorder owes its organizations, and the functions that are the only writers of its tables.
- [Resource Pools](resource-pools.md) — the per-organization container: workspace assignments, the two resolution directions, and pool-grain usage.
- [Providers and Integrations](provider-integration.md) — the registry and its connections, boot-time manifests, resource-key ownership, desired-state delivery, and the shared substrate.
- [Domains Registry](domains-registry.md) — claims as subtree ownership, placements as the only thing that serves, and the anti-abuse ledger.
+76
View File
@@ -0,0 +1,76 @@
---
title: "Acts"
audience: [developer]
summary: "The act record: one header row per action that writes several rows, the plan acts' detail tables, the obligations a plan act owes its organizations, and the functions that are the only writers of its tables."
---
# Acts
## Purpose
An **act** is one action by one actor that writes several rows at once, such as an operator reordering a ladder's tiers. The act record answers "who did this, when, and why" once, in a header row, so that every table the action wrote can point at the same row instead of repeating the answer. The header is `core.acts`: a **cause** from a closed list, the **actor**, an optional **note**, the request id, and two times.
Only plan acts exist today. A **plan act** changes what the plan ladders mean: it adds a tier, removes a tier, reorders a ladder's tiers, changes the default ladder of an organization type, or retries the unfinished work of one of those. What a plan act did goes in `core.plan_act_details`, one row per act, naming the ladder, product and organization type involved by id and by the name each had when the act committed. A later kind of act adds its own causes and its own detail table beside this one, and shares the header.
A default change, and a reorder that gives a default ladder a new rank-0 product, change the plan that every organization of one or more **organization types** (the `core.org_types` rows, one per kind of organization) should hold. The act commits first, together with the org types it **reaches**, in one short transaction. The organizations are moved afterwards, each in its own transaction under the shared materialization rendezvous (the lock described in `docs/database-locks.md`), so the exclusive rendezvous is held only while the act commits and not while every organization of the type is moved. The work left over is recorded as **obligations**: one row for each pool the act still owes a move. A **listing** writes the obligations in batches, and a **move** carries out one obligation in its own transaction. The request that made the act performs the first batch and the first moves; a background **drain** performs the rest.
Vocabulary reused from the [Plan Ladders and Transitions card](plan-ladders-transitions.md): ladder, tier, rank, position, transition, default restoration, supersession. From the [Entitlements card](entitlements.md): grant, provision, resource pool, materialization. A **disposition** is the operator's choice for positions held through the outgoing default: **grandfather** (the pool keeps its old plan under a new legacy grant) or **migrate** (the old position ends and the pool is restored onto the new default). The outgoing default is a default-sourced position that does not deliver the new default's rank-0 product, or any default-sourced position when the new default is none.
## Where this lives
Database objects sit in the `core` schema.
| Piece | What it is | Where |
|---|---|---|
| `core.acts` | The header: `cause`, the actor triple (`actor_type` with `actor_person_id` or `actor_service_account_id`), `note`, `request_id`, `effective_at` (when the act took effect) and `created_at` (when the row was written) | `00027_acts.sql` |
| `core.plan_act_details` | The plan act's detail, keyed by the act and tied to it by a composite foreign key on `(act_id, cause)`: ladder, product, previous product, org type, previous ladder (each id with its name), `disposition`, and `retried_act_id` for a Retry | `00027_acts.sql` |
| `core.record_plan_act` | The only writer of the two tables above and of the reached-types table; it returns the act's id | `00027_acts.sql`; replaced in `00028_plan_act_obligations.sql` to write the reached types |
| `core.plan_act_reached_org_types` | One row per org type an act reaches: the type's name, the greatest organization id of the type when the act committed (`last_org_id`), and `reached_seq`, an identity value that orders the acts reaching one type | `00028_plan_act_obligations.sql` |
| `core.plan_act_orgs_without_pool` | The organizations of a reached type that had no active default pool when the listing reached them; an obligation is keyed by a pool, so these cannot have one | `00028_plan_act_obligations.sql` |
| `core.plan_act_obligations` | One row per (act, pool): `status` (`pending`, `settled`, `failed`, `superseded`), `outcome`, the attempt count, the step and text of the last error, and the acts that superseded or requeued it | `00028_plan_act_obligations.sql` |
| `core.grants.act_id` | A nullable column pointing a grant at the act that created it | `00028_plan_act_obligations.sql` |
| `core.plan_act_owns_org_type` | The ownership rule: whether a given act owns a given org type (invariant 9) | `00028_plan_act_obligations.sql` |
| `core.plan_act_listing_frontier` | The listing's position: per org type the act owns, the organization ids not yet listed | `00028_plan_act_obligations.sql` |
| `core.write_plan_act_obligations`, `core.settle_plan_act_obligation`, `core.record_plan_act_obligation_failure`, `core.requeue_plan_act_obligations` | The only writers of the obligation tables: a listing batch, a settlement, a failed attempt, and a Retry's requeue | `00028_plan_act_obligations.sql` |
| `core.plan_position_is_outgoing`, `core.plan_pool_bucket` | The rule for an outgoing default and the class of one pool (`would_fail`, `outgoing_default`, `already_at_default`, `other_source`, `would_initiate`) that a move reads | `00026_outgoing_default_rule.sql` |
| Plan act runner | `runPlanAct` opens an act's transaction, locks the pools the act names in ascending order, then runs the act; `writePlanAct` calls `core.record_plan_act`; `PlanActReason` builds the transition reason | `internal/entitlements/plan_act_runner.go` |
| Causes and obligation vocabularies | `ActCause`, `ObligationStep`, `ObligationOutcome`, mirrored from the constraints and compared with them by tests | `internal/entitlements/act_cause.go`, `internal/entitlements/plan_act_obligation.go` |
| The plan acts | `AddTier`, `DeleteTier`, `RemoveTier`, `ReorderTiers`, `ChangeOrgTypeDefault`, `RetryPlanAct` | `internal/entitlements/ladder_acts.go`, `tier_reorder.go`, `default_change.go`, `plan_act_retry.go` |
| Listing, moves and drain | `ListPlanActBatch` lists; `EnactObligation` moves one pool; `DrainPlanAct` is one pass of both, as the request and the drain run it | `internal/entitlements/plan_act_drain.go`, `plan_act_move.go` |
| Drain workflow and poller | `PlanActDrainWorkflow` repeats passes for one act; `PollPlanActs` finds every act that still owes work and starts its drain | `internal/workflows/entitlements/plan_act_drain_workflow.go` |
| Places | The org type card and the ladder page read the acts that still owe work and offer each its Retry | `internal/entitlements/plan_act_places.go`, `internal/server/operator_plan_acts.go` |
| Read-only role | `scripts/db-reader-role.sql` withholds `core.acts.note` from the `console_reader` role | `scripts/db-reader-role.sql` |
## Invariants
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags mark the enforcement mechanism: **[db]** means a schema constraint, trigger or privilege rejects violations; **[db-code]** means database-side code upholds it; **[app]** means only Go code upholds it, so every new code path must too.
1. **[db]** An act's actor is coherent. A `person` actor has a person id and no service account id; a `service_account` actor has the reverse; a `system` actor has neither (`act_actor_coherent`). Only a person's act carries a note (`act_note_by_a_person`). The person id is a foreign key to `core.persons`; the service account id has no foreign key because no service account table exists.
2. **[db]** An act's cause is one of a closed list (`chk_acts_cause`, repeated on the detail by `chk_plan_act_details_cause`), and a detail row belongs only to an act of its own cause (`plan_act_detail_is_its_act`). `plan_act_detail_shape` fixes which detail columns each cause fills and leaves empty. A later kind of act adds its causes by replacing `chk_acts_cause`; when it has its own detail table, that table carries its own cause constraint, so `chk_plan_act_details_cause` stays as it is.
3. **[db]** A detail names what it identifies. Each id column and its name column are both set or both empty (`plan_act_detail_names_what_it_ids`). The ids carry no foreign key to the catalog, so the record outlives a deleted ladder, product or org type, and the name keeps the row readable.
4. **[db]** `core.record_plan_act` is the only writer of `core.acts`, `core.plan_act_details` and `core.plan_act_reached_org_types`. It runs as `core_owner`; `core_writer` keeps SELECT and holds no insert, update or delete on them, and EXECUTE is granted to `core_writer` alone. Nothing updates or deletes a row after the function wrote it.
5. **[db-code]** `core.record_plan_act` refuses an act written without the materialization rendezvous: the exclusive rendezvous for a tier add, a tier removal, a reorder and a default change, because they change what the tiers and defaults mean, and the rendezvous in either mode for a Retry. A reorder that names a new rank-0 product must name the product the ladder holds at rank 0 when the function runs (the caller renumbers first), and a Retry must name a default change or a reorder.
6. **[db]** The note is the one column of the act record that `core_reader` and `console_reader` cannot read. It holds a person's own words, so `core_reader` receives a column-level SELECT on every other column of `core.acts`, and `scripts/db-reader-role.sql` lists `core.acts.note` among the columns the read-only role withholds. `core_owner` and `core_writer` read it. No plan act writes a note today.
7. **[db-code]** A request id longer than the column (100 characters) is not stored, because the client chose it and it exists only for correlating the act with log lines. It is never a key.
8. **[db-code]** The org types an act reaches are derived inside `core.record_plan_act` from the detail, so a caller cannot pass reached types that disagree with the detail. A default change reaches its org type. A reorder that gives the ladder a new rank-0 product reaches every org type whose `default_plan_ladder_id` is that ladder. Each reached row carries the type's name and the greatest organization id of the type at that moment (`NULL` for a type with no organization). A reorder that leaves rank 0 unchanged, a tier add, a tier removal and a Retry reach none.
9. **[db-code]** At most one act owns an org type. The owner is the act whose reached-types row for that type has the greatest `reached_seq` (`core.plan_act_owns_org_type`). Ownership changes only when a commit under the exclusive rendezvous adds a reached-types row, so every reader that holds the rendezvous or reads committed rows sees one owner. Each statement that lists, moves or counts obligations calls this function, and the listing, the moves and the counts treat an obligation on a type the act no longer owns as not the act's. `reached_seq` orders correctly only while its sequence keeps `CACHE 1`, its default.
10. **[db]** At most one act owes a pool at a time: a unique index on `pool_id` over the obligations whose status is `pending` or `failed` (`uq_plan_act_obligations_one_open`). An obligation is also unique by (act, org type, organization) (`uq_plan_act_obligations_listing`), and it names a reached type of its act (`fk_plan_act_obligations_reach`).
11. **[db]** An obligation's status and its columns agree. It has `settled_at` and `outcome` exactly when it is `settled` (`plan_act_obligation_settled_shape`), and it names `superseded_by_act_id` exactly when it is `superseded` (`plan_act_obligation_superseded_shape`). It never points at its own act as the act that superseded or requeued it (`plan_act_obligation_points_elsewhere`). The outcome and the failed step come from closed lists.
12. **[db-code]** An obligation changes status only through four functions, each owned by `core_owner` and executable by `core_writer` alone. `core.write_plan_act_obligations` inserts `pending` rows and marks the open rows of earlier acts on the same pools `superseded`. `core.settle_plan_act_obligation` settles a `pending` row and reports whether it did. `core.record_plan_act_obligation_failure` adds one attempt to a `pending` row and sets it `failed` when the attempts reach 5; the constant is inside the function so the database enforces it, and a Go test compares it with `planActMaxAttempts`. `core.requeue_plan_act_obligations` returns the failed rows of the act a Retry names, on the types that act still owns, to `pending` with the attempts reset, and refuses a Retry that this transaction did not write. No role the application uses holds a write that could return a failed row to pending, reopen a settled row or revive a superseded row by another path.
13. **[db-code]** A listing batch lists up to the requested number of organizations of the types the act owns, in (org type, organization id) order after the greatest pair already written, and no further than each type's `last_org_id`. It locks the batch's pools with FOR KEY SHARE in ascending pool id order, writes an obligation for each organization that has an active default pool, and writes a `core.plan_act_orgs_without_pool` row for each that has none. Each organization is written once even if two batches of one act overlap, because each insert names its primary key as the conflict arbiter. The batch finds where to resume by reading `core.plan_act_listing_frontier`.
14. **[app]** A move runs in one transaction. It locks the pool row, then the obligation row, and skips the obligation unless it is `pending` and its act still owns the type. It reads the org type's current default and the pool's class from `core.plan_pool_bucket`, applies the disposition, and settles the obligation in the same transaction, so a pool is either moved and settled or unchanged. The outcome is `already_at`, `untouched`, `initiated`, `left`, `grandfathered` or `migrated`. `left` means the pool had no position and no default was conferred. A pool that holds the outgoing default under an act with no disposition settles as `untouched` and logs a warning, because the disposition gate (invariant 15) should have refused the act.
15. **[app]** A default change or a reorder that reaches an org type and was given no disposition is refused, and writes nothing, when any organization of a reached type holds the outgoing default. The gate reads at most two entries of a partial index for each type, so its cost does not grow with the number of organizations.
16. **[app]** Everything a move writes is attributed to the act that put the obligation in motion: the act itself, or the Retry that requeued the obligation. Every grant the move creates carries that act's id in `core.grants.act_id`, and its transitions carry the operator of that act and the reason built from the original act's detail row. A move settled by a drain minutes later is still the operator's act.
17. **[db]** The record is never purged. No role the application uses holds DELETE on any table of the act record, and no function deletes from them. Because no obligation is deleted, `fk_plan_act_obligations_pool_id` keeps every pool an obligation names, and `fk_resource_pools_org_id` keeps its organization. `fk_grants_act_id` keeps an act while a grant points at it. The guard trigger on `core.organizations.org_type` (migration `00026`, shown on the Entitlements card) keeps an organization on the type its obligations name, because an obligation records the organization's type when the listing reached it and a changed type would make that record wrong.
## Dimensions
These axes are independent; hold them separately:
- **Header, detail, work**: `core.acts` says who and when for any kind of act; `core.plan_act_details` says what a plan act did; the reached types, the without-pool records and the obligations say what the act still owes. Only the first is shared by every kind.
- **Actor of the act vs actor of a transition**: the act records its actor as `person`, `service_account` or `system` with the id in one of two columns. The transition audit that `core.confer` and its siblings write records `operator`, `system` or `webhook` with a single `actor_id` (see the ladders card). A plan act writes both: its header names `person`, and each transition its moves write names `operator` with the person of the act that put the obligation in motion.
- **Cause vs disposition vs outcome**: the cause says which kind of plan act it was; the disposition (`grandfather`, `migrate`, `keep`) is the operator's choice for positions held through the outgoing default, stored on the detail; the outcome is what happened to one pool and is stored on its obligation. A tier removal uses `keep` and `migrate`; a reorder and a default change use `grandfather` and `migrate`.
- **Status vs ownership**: an obligation's `status` is its own row's state. Whether the row is still its act's to move is a separate fact, derived from `core.plan_act_owns_org_type`, because a newer act that reaches the type takes ownership without changing the older rows. A `superseded` status records that a newer act listed the same pool and owes the move instead; losing ownership alone leaves the status unchanged.
- **Request vs drain**: the same pass (`DrainPlanAct`) runs in the request that made the act and in the drain. The request gives up sooner: it lists one batch, tries at most 50 obligations whether or not each settles, stops once 2 seconds have passed since the request began, waits 500 ms for a lock, and ends 5 seconds after it began (`internal/entitlements/constants.go`). The drain waits 5 seconds for a lock and has no transaction timeout. A lock wait that ends a request's pass records no attempt on the obligation, because it says nothing about the organization.
- **Business time vs wall clock**: `effective_at` is when the act took effect, taken by the Go caller when it records the act; `created_at` is when the database wrote the row.
- **Id vs name**: an id in a detail identifies the catalog row the act touched; the name beside it is what that row was called at commit and is never rewritten. Display reads the name, and navigation reads the id.
+3 -1
View File
@@ -34,8 +34,9 @@ Database objects sit in the `core` schema; baseline DDL in `internal/db/migratio
| `core.resource_keys.over_limit_behavior`, `over_limit_consequence` | The provider's declaration of what it does with a surplus (`deny_new`, `park`, `reclaim`) and one sentence, in the provider's words, describing that behavior; stamped by the integration at boot, never operator-edited, platform-owned keys CHECKed to `deny_new` | `00018_entitlement_set_changes.sql` |
| `core.entitlement_set_changes`, `_change_effects`, `_change_obligations` | The rule-change ledger: one append-only act row per rule change with the rule snapshotted before and after, one effect row per pool and key that moved (carrying the changed rule's reduction policy and, since migration 19, the policy governing the pool and key), and the mutable obligations a commit above the synchronous cap leaves for the drain | `00018_entitlement_set_changes.sql`, `00019_effect_governing_policy.sql` |
| `core.commit_rule_change` | The sole writer of rule changes: files the act, rewrites the rule and leaves one obligation per carrying pool; it never materializes, and the commit settles those obligations up to the cap and the drain the rest | `00018_entitlement_set_changes.sql` |
| `core.grants` | The decree ledger: who granted which product to whom and why, with `extends_grant_id` chaining a replacement grant to its ancestor and `resumes_after_supersession` marking a grant that delivers again when the plan that replaced it ends | `00001_init.sql`; reshaped in `00004`; the mark in `00023` |
| `core.grants` | The decree ledger: who granted which product to whom and why, with `extends_grant_id` chaining a replacement grant to its ancestor and `resumes_after_supersession` marking a grant that delivers again when the plan that replaced it ends, and `act_id`, a nullable foreign key to `core.acts` naming the plan act that created the grant (the [Acts card](acts.md)); a grant written by any other path, such as an operator's issue or a signup, has `NULL` | `00001_init.sql`; reshaped in `00004`; the mark in `00023`; `act_id` in `00028` |
| `core.pool_provisions` | The delivery ledger: one row per (pool, product, source) with quantity, time bounds, and the `active`/`suspended`/`ended` status that is the currently-delivering fact | `00001_init.sql`; `00004` |
| `core.pool_provision_ladders.org_type`, `default_sourced` | Two copies on each occupancy row. `org_type` is the type of the pool's organization; `default_sourced` is true when the row's provision comes from a grant whose reason is `default`, and false for a subscription, a purchase or a grant of any other reason. They serve the rule for an outgoing default: `core.plan_position_is_outgoing` marks a default-sourced position outgoing when the target plan is none or a different product, `core.plan_pool_bucket` classes a pool from what its positions show, and the partial index `idx_pool_provision_ladders_live_default` over live default-sourced rows, by (`org_type`, `product_id`), lets `core.outgoing_default_exists` answer in two index entries for each type. The Go queries call these functions, so the rule has one statement. The trigger `core.copy_position_sources` fills both copies on insert and when a row moves to another pool or provision; five guard triggers keep their sources fixed (invariant 18) | `00026_outgoing_default_rule.sql` |
| `core.resource_pools`, `core.pool_assignments` | The per-organization container entitlements materialize onto, and its links to workspaces | `00001_init.sql` |
| `core.numeric_entitlements`, `core.numeric_entitlement_contributions`, `core.numeric_entitlement_usage` | The materialized numeric limits, the per-provision contribution ledger explaining each limit, and the usage counters consumers increment | `00001_init.sql` |
| `core.boolean_entitlements` | Materialized on/off capabilities: `granted` true while at least one active provision carries the rule; rows lapse to false but are never deleted, so an absent row always means never-conferred | `00007_boolean_entitlements.sql` |
@@ -72,6 +73,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
15. **[db-code]** Rule changes reach existing pools: `core.commit_rule_change` is the only path that writes a rule, and it files one act row and one pending obligation per pool carrying the set. The materializer recomputes each of those pools and settles its obligation through `core.settle_obligation`, which writes that pool's effects: inside the commit's transaction up to the cap, and from the drain beyond it.
16. **[db]** `grants.resumes_after_supersession` is NOT NULL and defaults to false, and a CHECK (`chk_grants_default_never_resumes`) keeps it false on every `default` grant, since the floor restores the default by minting a fresh grant. It and the note (`description`) are the grant columns an operator changes in place apart from revocation: the Manage action writes those two columns and nothing else when the submitted Valid until is unchanged, and when Valid until changes it writes a successor grant carrying the submitted mark and note instead. When the mark is read, and what a resumed grant does, is the ladders card's invariant 18.
17. **[db-code]** A change to a stored entitlement's delivered values writes a desired-state outbox row in the same transaction. Row-level triggers on `core.boolean_entitlements` and `core.numeric_entitlements` write one row naming the pool and the key when a boolean's `granted` flips, when a numeric `resource_limit` changes, or when either row's pool or key moves; a lapsed boolean and a limit that is not positive (zero, or the -1 the data model names unlimited, whose delivery is forge issue #164) count as not held, so inserting or deleting one writes nothing. An idempotent materialization rewrites values that are already stored, which changes no delivered value, so it writes no row; and a rule change that refuses a pool is refused whole, so no row names any pool it materialized. The triggers read no other table, so the materializer's lock order gains no step. What the rows feed, desired-state records per connection and recipient, is the [Providers and Integrations card](provider-integration.md)'s subject.
18. **[db]** The two copies on `core.pool_provision_ladders` stay true because the five facts they rest on cannot change: a pool's organization, an organization's type, a provision's grant, a provision's pool (from which a position's `pool_id` is synced) and a grant's reason. Five triggers refuse an update that changes `core.resource_pools.org_id`, `core.organizations.org_type`, `core.pool_provisions.grant_id`, `core.pool_provisions.pool_id` or `core.grants.grant_reason`, and raise `restrict_violation` through `core.refuse_position_source_change`; an update that assigns the same value passes. No runtime statement changes any of them. A feature that must change one decides what the copies do and replaces the guard in its own migration.
## Dimensions
+4 -4
View File
@@ -34,7 +34,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| `core.product_conferral_shapes` | The view conferral reads for a product's ladder positions and availability facts (introduced in the Product Catalog card) | `00004_doc41_schema_and_data.sql` |
| `core.grant_replacements` | A view of which source replaced a grant: it pairs a grant provision's `end` transition with the typed transition of the grant or subscription that took the rung in the same pool, on the same ladder, at the same `effective_at`, by the same actor, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql` |
| `core.transition_change_kind` | The kind of change one transition records, which the Tier changes Change badge and the recent-activity line name: `resumption` for a resumption's start, `extension` for an extension's transfer or a legacy `extend` row, otherwise the row's type. A machine reason counts only from the writer that writes it, because Issue grant copies the operator's note into the reason | `00025_superseded_transitions.sql` |
| `core.superseded_ends` | A view of the `end` rows a typed row of the same act states: an `end` and a typed row with a from side in the same pool, at the same `effective_at`, by the same actor, with the same reason, on any ladder, since supersession ends every rung of the incumbent. The Tier changes list leaves out every one; the recent-activity feed leaves out only those an extension's transfer states (`by_extension`). Nothing records the act as a column yet; when every row an act writes points at its act, the view reads that pointer and only its body changes. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
| `core.superseded_ends` | A view of the `end` rows a typed row of the same act states: an `end` and a typed row with a from side in the same pool, at the same `effective_at`, by the same actor, with the same reason, on any ladder, since supersession ends every rung of the incumbent. The Tier changes list leaves out every one; the recent-activity feed leaves out only those an extension's transfer states (`by_extension`). A grant that a plan act creates already points at its act in `core.grants.act_id` (the [Acts card](acts.md)), but a transition row has no such column, so the view still matches the rows by pool, instant, actor and reason. When the writers of transitions record their act (#179), the view reads that pointer and only its body changes. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
| `core.extension_handoffs` | A view of the transitions that record an extension, each with the grant it handed the position to. The recent-activity feed leaves those grants' rows out: the transfer, which reads "Extended", is the extension's one row. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
| `core.grant_waits` | The one statement of the chain walk and the resumption conditions (invariant 18): per lineage head with a current replacement, its holder and its chain's top at an instant, whether each delivers, whether it resumes, and what keeps it from resuming | `00023_grant_resumption.sql` |
| `core.resumable_grants` | The rows of `core.grant_waits` that resume at an instant, with their holder. Resumption, the operator's delivery states, the member's held rung and the cancel confirmation's prediction all read it | `00023_grant_resumption.sql` |
@@ -46,9 +46,9 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it, and "held" in it means an active attachment | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, reorder and removal (with a preview of what happens to pools sitting on affected rank-0 tiers; removing the last tier of a ladder an org type defaults to is refused, because signups land on that ladder's entry tier) | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go` |
| Operator ladder management | Ladder and tier CRUD, tier add, tier delete, tier removal and reorder. Each of the four tier changes is a plan act: one function in `internal/entitlements` runs it in one transaction under the exclusive materialization rendezvous and records it in the act record, a header and a detail row (the [Acts card](acts.md)). A reorder that gives a ladder a new rank-0 product also reaches every org type that defaults to the ladder: the act's listing then writes one obligation for each organization of those types that has a default pool and a record for each that has none, and the drain moves each pool onto the new rank-0 product (the Acts card describes both). The removal preview shows what happens to pools sitting on affected rank-0 tiers. Removing the last tier of a ladder an org type defaults to is refused on both paths, the delete of a tier no one holds (`DeleteTier`) and the removal of a held tier (`RemoveTier`), because both call `LastTierRefusal` and signups land on that ladder's entry tier | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go`, `internal/entitlements/plan_act_runner.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
| Org-type default changes | Changing a type's default ladder, with a per-pool preview offering two dispositions: **grandfather** (the pool keeps its old position, re-decreed as a legacy grant) or **migrate** (end the old position, then floor-guarded restore onto the new default) | `internal/server/operator_org_types.go`, `internal/entitlements/default_change.go` |
| Org-type default changes | Changing a type's default ladder, with a per-pool preview offering two dispositions: **grandfather** (the pool keeps its old position, re-decreed as a legacy grant) or **migrate** (end the old position, then floor-guarded restore onto the new default). `ChangeOrgTypeDefault` saves the new default and records the act and the org type it reaches in one short transaction. The act's listing then writes one obligation for each organization of the type that has a default pool, and a record for each that has none, in batches, and a move per obligation applies the disposition in that organization's own transaction. The request settles the first moves, and the act's drain (`PlanActDrainWorkflow`, started by the request and, as a backstop, by the poller `PollPlanActs`) settles the rest. When no disposition was chosen and an organization of the type holds the outgoing default, the change is refused with a disposition-required error and the act, rolled back with its transaction, records nothing | `internal/server/operator_org_types.go`, `internal/entitlements/default_change.go`, `internal/entitlements/plan_act_drain.go`, `internal/entitlements/plan_act_move.go` |
| Plan topology | The ladders-by-ranks overview grid | `internal/server/operator_topology.go` |
| Grant expiry | `GrantExpirationWorkflow` sleeps until a grant's expiry and then runs the expiration activity: end the grant's positions, restore the default, rematerialize | `internal/workflows/entitlements/grant_expiration_workflow.go`; `activities.go` |
@@ -72,7 +72,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
14. **[app]** Tier ranks are contiguous from 0 only by authoring discipline: the schema enforces only per-ladder rank uniqueness; the operator UI appends and renumbers to keep 0..N-1.
15. **[db]** A ladder is identified by its `plan_ladder_id`, presented by its display name, and addressed declaratively by a nullable `key` (`00012_entity_keys.sql` renamed `ladder_key` to `key` and made it nullable; the rule is in `docs/identifiers.md` and the `ui-vocabulary` spec). The key is what a seed, a configuration file, a test, a script, or an API client names to reach the ladder without knowing its generated id; it is `TEXT NULL`, matches `^[a-z][a-z0-9_]*$` with at most 64 characters, and is unique across the table (`uq_plan_ladders_key`). Neither the create form nor the edit form collects it, so a ladder an operator creates carries none; the ladder's detail page renders it read-only when set and renders nothing when NULL. A key addresses whichever ladder holds it now, so a seed ensures the row exists and never overwrites the fields of the row it lands on.
16. **[db]** A ladder's display name is unique, compared without regard to case (`uq_plan_ladders_name_ci`). The name is what every catalog and composite surface leads with, so two ladders sharing one would be indistinguishable to an operator. Migration `00012` deduped colliding names before adding the index, and folded the existing ladder keys to the key grammar (lower-cased, separator runs to `_`, leading non-letters removed), clearing any value the grammar still rejected and any that collided after folding.
17. **[app]** Audit reasons that name a ladder (`plan-ladder tier reorder (…)`, `tier removal (… from …)`) are composed at the moment of the action and stored on the transition rows, so they are write-time snapshots: they record the name as it read then, and a later rename does not rewrite them. An act that cannot read the ladder's row refuses at the read of names and records nothing.
17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder <name>`, `tier removal (<product> from <ladder>)` and `plan-ladder tier reorder (<ladder>)`. The default change names the org type's key: `org-type default change (<org type>)`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
@@ -94,11 +94,11 @@ Each numbered section below is one commit, and its items are that commit's parts
## 9. Docs that describe the new structures
- [ ] 9.1 `docs/models/plan-ladders-transitions.md`: the map rows for ladder management and org-type default changes name the acts, the listing, the obligations and the drain; invariant 17 states that every plan act's reason comes from one builder and names the ladder; the `core.superseded_ends` row says grants already point at their act and transitions will once #179 replaces their writers; the removal's last-tier refusal is true for both paths.
- [ ] 9.2 Add `docs/models/acts.md` (Purpose, Where this lives, Invariants, Dimensions, per `docs/models/README.md`'s anatomy and style contract) for the act record: the header, the causes, the actor triple, the note and who may read it, the request id, the detail tables, the reached types and ownership (one function states it), the rows that point at an act, the obligations and their statuses, the enclosure; and one sentence that the record is never purged, so the obligations' foreign key to pools and the grants' foreign key to acts keep a pool, and so its organization, from being deleted, and that the guard on an organization's type keeps an act's obligations on the type that was reached. List it in `docs/models/README.md`.
- [ ] 9.3 `docs/models/entitlements.md`: `core.grants.act_id`, and the position table's `org_type` and `default_sourced` copies with the rule they serve and the guard that keeps their four sources fixed.
- [ ] 9.4 `docs/go-conventions.md` "Where a rule and an act live": the plan acts' runner is the pattern for an act that locks several pools, and `core.plan_position_is_outgoing` and `core.plan_act_owns_org_type` are examples of a rule SQL and Go both read.
- [ ] 9.5 `status/design-feedback.md`: the act record and its detail tables are not in the design's data model; the transitions' actor convention differs from the audit triple the act uses; the position table carries two copies the data model does not list.
- [x] 9.1 `docs/models/plan-ladders-transitions.md`: the map rows for ladder management and org-type default changes name the acts, the listing, the obligations and the drain; invariant 17 states that every plan act's reason comes from one builder and names the ladder, or for a default change the org type; the `core.superseded_ends` row says grants already point at their act and transitions will once #179 replaces their writers; the removal's last-tier refusal is true for both paths.
- [x] 9.2 Add `docs/models/acts.md` (Purpose, Where this lives, Invariants, Dimensions, per `docs/models/README.md`'s anatomy and style contract) for the act record: the header, the causes, the actor triple, the note and who may read it, the request id, the detail tables, the reached types and ownership (one function states it), the rows that point at an act, the obligations and their statuses, the enclosure; and one sentence that the record is never purged, so the obligations' foreign key to pools keeps a pool, and so its organization, from being deleted, and the grants' foreign key keeps an act a grant points at, and that the guard on an organization's type keeps an act's obligations on the type that was reached. List it in `docs/models/README.md`.
- [x] 9.3 `docs/models/entitlements.md`: `core.grants.act_id`, and the position table's `org_type` and `default_sourced` copies with the rule they serve and the guard triggers that keep the facts they copy fixed.
- [x] 9.4 `docs/go-conventions.md` "Where a rule and an act live": the plan acts' runner is the pattern for an act that locks several pools, and `core.plan_position_is_outgoing` and `core.plan_act_owns_org_type` are examples of a rule SQL and Go both read.
- [x] 9.5 `status/design-feedback.md`: the act record and its detail tables are not in the design's data model; the transitions' actor convention differs from the audit triple the act uses; the position table carries two copies the data model does not list.
## 10. Landing
+15
View File
@@ -4,6 +4,21 @@ These entries are feedback owed to the upstream design repository, where the
shared data model and its documents are maintained. They are kept here,
verbatim as they were filed, until that repository has a tracker of its own.
### Acts: the act record and its detail tables are not in the data model
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
Filed 2026-10-19 with migrations `00027_acts.sql` and `00028_plan_act_obligations.sql`. `design/data-model.md` has no table for an action that writes several rows. Its only record of an action apart from the rows it wrote is `audit_logs`, a partitioned log that, by its own text, no other table references by foreign key. The console adds `core.acts`, a header with a cause from a closed list, the actor, an optional note, a request id and two times, and `core.plan_act_details`, which names what a plan act did by id and by the name each had when the act committed. Three more tables hold the work a default change or a reorder leaves for its organizations: `core.plan_act_reached_org_types`, `core.plan_act_orgs_without_pool` and `core.plan_act_obligations`; the nullable `core.grants.act_id` points a grant at the act that created it. Other tables reference the header by foreign key, so it is a record the model depends on and not a log beside it. Later kinds of act add their causes to the closed list and a detail table of their own. Upstream fix: add an act header and a plan act detail to the data model, with the closed cause list, the rule that the ids in a detail carry no foreign key to the catalog so the record outlives a deleted row, and the nullable act pointer on grants.
### Actors: the transitions name an actor one way, the act another
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
Filed 2026-10-19 with migration `00027_acts.sql`. `design/data-model.md` defines `pool_provision_transitions.actor_type` as `operator`, `system` or `webhook`, with one `actor_id` that is a person for `operator` and may be empty for `system` and `webhook`. The same document defines the audit log's actor as `person`, `service_account` or `system`, with `actor_person_id` and `actor_service_account_id` as separate foreign key columns. The act record uses the audit log's triple, with a CHECK that exactly the column its type names is set (`act_actor_coherent`). A plan act's move therefore writes two spellings for one actor: the act header says `person` with the person's id, and each transition the move writes says `operator` with the same id. The console keeps both as designed. Upstream fix: state one actor vocabulary for the transitions and the audit log, or state the mapping between them, including which audit actor a `webhook` row maps to, so a reader joining the two does not guess.
### Positions: the occupancy table carries two copies the data model does not list
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
Filed 2026-10-19 with migration `00026_outgoing_default_rule.sql`. `design/data-model.md` lists `pool_provision_ladders` with four denormalized columns, `pool_id`, `status`, `activated_at` and `ended_at`, kept in sync from `pool_provisions` by trigger. The console adds `org_type`, the type of the pool's organization, and `default_sourced`, whether the row's provision comes from a grant whose reason is `default`. Both are NOT NULL and are filled by trigger when a row is inserted or moves to another pool or provision. They let one partial index, over live default-sourced rows by (`org_type`, `product_id`), answer "does any organization of this type hold an outgoing default" from two index entries for each type, so the answer does not take longer as the number of organizations grows. The copies are true only while four facts stay fixed: a pool's organization, an organization's type, a provision's grant and a grant's reason. Five triggers refuse an update to any of them. Upstream fix: list the two columns beside those four, and say that an organization's type, a pool's organization, a provision's grant and a grant's reason do not change after they are written, or name the work a feature must do to change one.
### Grants: a standing grant can deliver again, but no entry point says when
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`