Files
member-console/docs/models/acts.md
T
cgalo5758 9b0e2e5677 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.
2026-10-19 22:12:33 -04:00

18 KiB

title, audience, summary
title audience summary
Acts
developer
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: ladder, tier, rank, position, transition, default restoration, supersession. From the Entitlements card: 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.