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.
18 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Acts |
|
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.
- [db] An act's actor is coherent. A
personactor has a person id and no service account id; aservice_accountactor has the reverse; asystemactor 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 tocore.persons; the service account id has no foreign key because no service account table exists. - [db] An act's cause is one of a closed list (
chk_acts_cause, repeated on the detail bychk_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_shapefixes which detail columns each cause fills and leaves empty. A later kind of act adds its causes by replacingchk_acts_cause; when it has its own detail table, that table carries its own cause constraint, sochk_plan_act_details_causestays as it is. - [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. - [db]
core.record_plan_actis the only writer ofcore.acts,core.plan_act_detailsandcore.plan_act_reached_org_types. It runs ascore_owner;core_writerkeeps SELECT and holds no insert, update or delete on them, and EXECUTE is granted tocore_writeralone. Nothing updates or deletes a row after the function wrote it. - [db-code]
core.record_plan_actrefuses 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. - [db] The note is the one column of the act record that
core_readerandconsole_readercannot read. It holds a person's own words, socore_readerreceives a column-level SELECT on every other column ofcore.acts, andscripts/db-reader-role.sqllistscore.acts.noteamong the columns the read-only role withholds.core_ownerandcore_writerread it. No plan act writes a note today. - [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.
- [db-code] The org types an act reaches are derived inside
core.record_plan_actfrom 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 whosedefault_plan_ladder_idis that ladder. Each reached row carries the type's name and the greatest organization id of the type at that moment (NULLfor a type with no organization). A reorder that leaves rank 0 unchanged, a tier add, a tier removal and a Retry reach none. - [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_seqorders correctly only while its sequence keepsCACHE 1, its default. - [db] At most one act owes a pool at a time: a unique index on
pool_idover the obligations whose status ispendingorfailed(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). - [db] An obligation's status and its columns agree. It has
settled_atandoutcomeexactly when it issettled(plan_act_obligation_settled_shape), and it namessuperseded_by_act_idexactly when it issuperseded(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. - [db-code] An obligation changes status only through four functions, each owned by
core_ownerand executable bycore_writeralone.core.write_plan_act_obligationsinsertspendingrows and marks the open rows of earlier acts on the same poolssuperseded.core.settle_plan_act_obligationsettles apendingrow and reports whether it did.core.record_plan_act_obligation_failureadds one attempt to apendingrow and sets itfailedwhen the attempts reach 5; the constant is inside the function so the database enforces it, and a Go test compares it withplanActMaxAttempts.core.requeue_plan_act_obligationsreturns the failed rows of the act a Retry names, on the types that act still owns, topendingwith 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. - [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 acore.plan_act_orgs_without_poolrow 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 readingcore.plan_act_listing_frontier. - [app] A move runs in one transaction. It locks the pool row, then the obligation row, and skips the obligation unless it is
pendingand its act still owns the type. It reads the org type's current default and the pool's class fromcore.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 isalready_at,untouched,initiated,left,grandfatheredormigrated.leftmeans the pool had no position and no default was conferred. A pool that holds the outgoing default under an act with no disposition settles asuntouchedand logs a warning, because the disposition gate (invariant 15) should have refused the act. - [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.
- [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. - [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_idkeeps every pool an obligation names, andfk_resource_pools_org_idkeeps its organization.fk_grants_act_idkeeps an act while a grant points at it. The guard trigger oncore.organizations.org_type(migration00026, 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.actssays who and when for any kind of act;core.plan_act_detailssays 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_accountorsystemwith the id in one of two columns. The transition audit thatcore.conferand its siblings write recordsoperator,systemorwebhookwith a singleactor_id(see the ladders card). A plan act writes both: its header namesperson, and each transition its moves write namesoperatorwith 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 useskeepandmigrate; a reorder and a default change usegrandfatherandmigrate. - Status vs ownership: an obligation's
statusis its own row's state. Whether the row is still its act's to move is a separate fact, derived fromcore.plan_act_owns_org_type, because a newer act that reaches the type takes ownership without changing the older rows. Asupersededstatus 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_atis when the act took effect, taken by the Go caller when it records the act;created_atis 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.