State a supersession pair once, in core.supersession_pairs

A conferral that supersedes a ladder's incumbent writes two transitions
at one instant by one actor: the incumbent's end and the successor's
typed row. Migration 00033 adds core.supersession_pairs, which joins
those two rows of core.plan_transitions on pool, ladder, instant, actor
type and actor id, with a from-side on the typed row, and names both
provisions. A restoration (from_rank NULL) is not a pair. The view does
not group: a transaction that confers twice on one ladder at one
instant yields every end and typed combination. core.grant_replacements
is re-expressed over the view, its rows unchanged; the Down restores
00023's body before dropping the view. The console reader may select
the view (its column list gains five lines), and
docs/models/plan-ladders-transitions.md lists it.

test/pin-timestamps.sql now walks base tables only. It updated every
column information_schema.columns names, views included, and the demo
snapshot's rebuild failed on the new view ("cannot update view
supersession_pairs", the first suite run of this section stopped at the
app database reset). No existing view carries a pinned column, so the
snapshot's pinned values do not change.

Tests (internal/entitlements): the pair names both provisions; an
expiry's restoration is not a pair; a whole-bundle end pairs only the
contested ladder; a chain of acts pairs each end at its own instant; an
expiry's end is not paired with a subscription's conferral. The
extension filter's ep.grant_id clause has no break: only a grant
extension succeeding a subscription would need it, and no test builds
that case.

Breaks (scripts/mutation-check, command records on 00033, 8 of 8
CAUGHT):
- view without from_rank
- view without same ladder
- grant_replacements without extension filter
- no SELECT grant on the view
- view selects reason
- view swaps the two provisions
- view without same instant
- view without same actor type

The suite on reset databases passes, 4627 tests, 15 skipped. make lint
passes; openspec validate --strict passes.
This commit is contained in:
2026-10-24 14:43:57 -04:00
parent 1b1ab1d849
commit 8181d5461c
7 changed files with 268 additions and 14 deletions
+2 -1
View File
@@ -32,7 +32,8 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| `core.sync_pool_provision_ladders()` | The trigger that propagates a provision's status down to its episode rows — except rows already ended, which stay ended | `00001_init.sql`; `00004_doc41_schema_and_data.sql` |
| The five conferral functions | `confer`, `end_conferral`, `sync_source_status`, `align_conferral_shape`, `update_conferral_bounds` — verbs glossed in Purpose; the only legal writers of the three position tables | `00005_doc41_conferral_functions.sql`; `00023_grant_resumption.sql` (replaces `confer`, so its retired-product test follows a grant's extensions) |
| `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.supersession_pairs` | The one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason` | `00033_supersession_pairs.sql` |
| `core.grant_replacements` | A view of which source replaced a grant: it reads its pairs from `core.supersession_pairs` and keeps those whose ended provision is a grant's and whose successor is the grant or subscription that took the rung, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql`; `00033_supersession_pairs.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`). 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` |
@@ -0,0 +1,94 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- +goose StatementBegin
-- ---------------------------------------------------------------------------
-- One statement of a supersession pair (openspec change
-- displaced-subscription-waits). core.confer writes the superseded
-- provision's 'end' row and the successor's typed row on the same ladder, in
-- the same pool, at the same effective_at, with the same actor. The view
-- pairs those two rows, whatever the two provisions' sources (a grant, a
-- subscription or the default). core.grant_replacements, which 00023 wrote
-- as that join for grants alone, now reads its pairs from the view with its
-- rows unchanged. If transitions come to point at the act that wrote them
-- (#179), only this view's body changes.
--
-- The view reads no pool_provision_transitions.reason, which the reader role
-- withholds: scripts/db-reader-role.sql withholds whole any view that reads
-- a withheld column, so the reader role reads this one.
--
-- from_rank IS NOT NULL keeps out the start a restoration or a resumption
-- writes after an end in the same act, which has no from side. The view does
-- not group: a transaction that confers twice on one ladder at one instant
-- pairs each end row with each typed row, the middle provision's own among
-- them, and core.grant_replacements' grouping collapses them.
-- ---------------------------------------------------------------------------
CREATE VIEW core.supersession_pairs AS
SELECT
e.pool_id,
e.plan_ladder_id,
e.effective_at,
e.provision_id AS ended_provision_id,
n.provision_id AS successor_provision_id
FROM core.pool_provision_transitions e
JOIN core.pool_provision_transitions n
ON n.pool_id = e.pool_id
AND n.plan_ladder_id = e.plan_ladder_id
AND n.effective_at = e.effective_at
AND n.actor_type = e.actor_type
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
WHERE e.transition_type = 'end'
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
AND n.from_rank IS NOT NULL;
CREATE OR REPLACE VIEW core.grant_replacements AS
SELECT
ep.grant_id,
np.grant_id AS replacing_grant_id,
np.subscription_id AS replacing_subscription_id,
sp.pool_id,
max(sp.effective_at) AS replaced_at
FROM core.supersession_pairs sp
JOIN core.pool_provisions ep ON ep.provision_id = sp.ended_provision_id
JOIN core.pool_provisions np ON np.provision_id = sp.successor_provision_id
LEFT JOIN core.grants ng ON ng.grant_id = np.grant_id
WHERE ep.grant_id IS NOT NULL
AND (np.grant_id IS NOT NULL OR np.subscription_id IS NOT NULL)
AND np.grant_id IS DISTINCT FROM ep.grant_id
AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id
GROUP BY ep.grant_id, np.grant_id, np.subscription_id, sp.pool_id;
GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
CREATE OR REPLACE VIEW core.grant_replacements AS
SELECT
ep.grant_id,
np.grant_id AS replacing_grant_id,
np.subscription_id AS replacing_subscription_id,
e.pool_id,
max(e.effective_at) AS replaced_at
FROM core.pool_provision_transitions e
JOIN core.pool_provisions ep ON ep.provision_id = e.provision_id
JOIN core.pool_provision_transitions n
ON n.pool_id = e.pool_id
AND n.plan_ladder_id = e.plan_ladder_id
AND n.effective_at = e.effective_at
AND n.actor_type = e.actor_type
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
JOIN core.pool_provisions np ON np.provision_id = n.provision_id
LEFT JOIN core.grants ng ON ng.grant_id = np.grant_id
WHERE e.transition_type = 'end'
AND ep.grant_id IS NOT NULL
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
AND n.from_rank IS NOT NULL
AND (np.grant_id IS NOT NULL OR np.subscription_id IS NOT NULL)
AND np.grant_id IS DISTINCT FROM ep.grant_id
AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id
GROUP BY ep.grant_id, np.grant_id, np.subscription_id, e.pool_id;
DROP VIEW core.supersession_pairs;
-- +goose StatementEnd
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// core.supersession_pairs (openspec change displaced-subscription-waits,
// task 3.4): the one statement of a supersession. Each test runs in the
// world's rolled-back transaction; the world is described in
// grant_resumption_fixture_test.go.
import (
"testing"
"time"
)
// supersessionPair is one row of core.supersession_pairs.
type supersessionPair struct {
ladder, ended, successor string
at time.Time
}
// pairs reads the view's rows in the world's pool, ordered by ladder, ended
// provision and successor.
func (w *resumeWorld) pairs() []supersessionPair {
w.t.Helper()
rows, err := w.tx.QueryContext(w.ctx, `
SELECT plan_ladder_id, ended_provision_id, successor_provision_id, effective_at
FROM core.supersession_pairs
WHERE pool_id = $1
ORDER BY plan_ladder_id, ended_provision_id, successor_provision_id`, w.org.pool.PoolID)
if err != nil {
w.t.Fatalf("read supersession_pairs: %v", err)
}
defer rows.Close()
var out []supersessionPair
for rows.Next() {
var r supersessionPair
if err := rows.Scan(&r.ladder, &r.ended, &r.successor, &r.at); err != nil {
w.t.Fatalf("scan supersession_pairs: %v", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
w.t.Fatalf("read supersession_pairs: %v", err)
}
return out
}
func TestSupersessionPairs_NameBothProvisions(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
subProvision := p.conferSub(sub, p.plus)
g := p.grant(p.pro, grantOpts{})
grantProvision := p.confer(g)
at := p.clock
got := p.pairs()
if len(got) != 1 || got[0].ladder != p.wiki || got[0].ended != subProvision ||
got[0].successor != grantProvision || !got[0].at.Equal(at) {
t.Fatalf("pairs = %+v, want one on Wiki from the subscription's provision to the grant's", got)
}
if n := p.count("SELECT count(*) FROM core.grant_replacements WHERE pool_id = $1", p.org.pool.PoolID); n != 0 {
t.Errorf("grant_replacements has %d rows in the pool, want none: the ended provision is a subscription's", n)
}
}
func TestSupersessionPairs_ARestorationIsNotAPair(t *testing.T) {
p := newPlusProWorld(t)
g := p.grant(p.plus, grantOpts{validUntil: p.now.Add(-time.Hour)})
p.confer(g)
p.expire(g)
if got := p.holdsLadder(p.wiki); got != p.free {
t.Fatalf("precondition: Wiki holds %q after the expiry, want Free from the floor", got)
}
if got := p.pairs(); len(got) != 0 {
t.Errorf("the floor's restoration is paired with the expired grant's end: %+v", got)
}
if got := p.replacements(g.GrantID); len(got) != 0 {
t.Errorf("replacements = %+v, want none: the expiry's restoration is not a replacement", got)
}
}
func TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder(t *testing.T) {
p := newPlusProWorld(t)
docsFree := p.product("Docs Free")
docs := p.ladder("Docs", docsFree, p.plus)
sub := p.subscription()
p.conferSub(sub, p.plus)
if got := p.holdsLadder(docs); got != p.plus {
t.Fatalf("precondition: Docs holds %q, want the subscription's Plus", got)
}
p.confer(p.grant(p.pro, grantOpts{}))
if got := p.holdsLadder(docs); got != "" {
t.Fatalf("precondition: Docs holds %q after the grant, want nothing: the whole-bundle end vacated it", got)
}
got := p.pairs()
if len(got) != 1 || got[0].ladder != p.wiki {
t.Errorf("pairs = %+v, want one on Wiki", got)
}
for _, r := range got {
if r.ladder == docs {
t.Errorf("a pair on Docs, where the grant wrote no rung: %+v", r)
}
}
}
func TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant(t *testing.T) {
p := newPlusProWorld(t)
plusProv := p.confer(p.grant(p.plus, grantOpts{}))
proProv := p.confer(p.grant(p.pro, grantOpts{}))
maxProv := p.confer(p.grant(p.maxTier, grantOpts{}))
got := p.pairs()
want := map[[2]string]bool{{plusProv, proProv}: true, {proProv, maxProv}: true}
if len(got) != len(want) {
t.Fatalf("pairs = %+v, want two: an end row is paired with a typed row written at another instant", got)
}
for _, r := range got {
if !want[[2]string{r.ended, r.successor}] {
t.Errorf("an end row is paired with a typed row written at another instant: %+v", r)
}
}
}
func TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral(t *testing.T) {
p := newPlusProWorld(t)
g := p.grant(p.plus, grantOpts{validUntil: p.now.Add(-time.Hour)})
grantProv := p.confer(g)
p.expire(g)
if got := p.holdsLadder(p.wiki); got != p.free {
t.Fatalf("precondition: Wiki holds %q after the expiry, want Free from the floor", got)
}
p.conferSubAt(p.subscription(), p.pro, p.now)
for _, r := range p.pairs() {
if r.ended == grantProv {
t.Errorf("the expiry's end is paired with the subscription's conferral: %+v", r)
}
}
}
+5
View File
@@ -556,6 +556,11 @@ withhold core.superseded_ends.by_extension
withhold core.superseded_ends.pool_id
withhold core.superseded_ends.superseded_by
withhold core.superseded_ends.transition_id
read core.supersession_pairs.effective_at
read core.supersession_pairs.ended_provision_id
read core.supersession_pairs.plan_ladder_id
read core.supersession_pairs.pool_id
read core.supersession_pairs.successor_provision_id
read core.target_mappings.connection_id
read core.target_mappings.created_at
read core.target_mappings.display_name
@@ -53,7 +53,7 @@ None.
## Impact
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`.
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`, `test/pin-timestamps.sql` (base tables only, because the new view exposes `effective_at` and cannot be updated).
- **Schema:** two core migrations, `00033_supersession_pairs.sql` (a view, and `core.grant_replacements` re-expressed over it) and `00034_subscription_displacements.sql` (a function). No column, no data, no grant change beyond the new objects' own.
- **Stripe calls:** none added, none removed.
- **Routes:** none.
@@ -31,20 +31,27 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
## 3. One statement of a supersession pair
- [ ] 3.1 `internal/db/migrations/00033_supersession_pairs.sql` (design D1, Migration plan): a header comment in the style of `00023` and `00025` saying what the view pairs, that `core.grant_replacements` now reads it with its rows unchanged, that it reads no `reason` so the reader role reads it, and that #179 would change only its body; `CREATE VIEW core.supersession_pairs` and `CREATE OR REPLACE VIEW core.grant_replacements` as the design writes them; `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;`. Down: `CREATE OR REPLACE VIEW core.grant_replacements` with `00023`'s body, then `DROP VIEW core.supersession_pairs;`. Every multi-statement section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
- [ ] 3.2 `internal/migrate/testdata/console-reader-columns.txt`: the view's five columns, `effective_at`, `ended_provision_id`, `plan_ladder_id`, `pool_id`, `successor_provision_id`, each marked `read`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` and checked in the diff to be those five lines only.
- [ ] 3.3 `docs/models/plan-ladders-transitions.md`, "Where this lives": a row for `core.supersession_pairs` (the one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason`; `00033_supersession_pairs.sql`), and the `core.grant_replacements` row says that it reads its pairs from that view (`00023_grant_resumption.sql`; `00033_supersession_pairs.sql`).
- [ ] 3.4 Tests, `internal/entitlements/supersession_pairs_test.go` (package `entitlements_test`, over `newPlusProWorld`, in the world's rolled-back transaction):
- [x] 3.1 `internal/db/migrations/00033_supersession_pairs.sql` (design D1, Migration plan): a header comment in the style of `00023` and `00025` saying what the view pairs, that `core.grant_replacements` now reads it with its rows unchanged, that it reads no `reason` so the reader role reads it, and that #179 would change only its body; `CREATE VIEW core.supersession_pairs` and `CREATE OR REPLACE VIEW core.grant_replacements` as the design writes them; `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;`. Down: `CREATE OR REPLACE VIEW core.grant_replacements` with `00023`'s body, then `DROP VIEW core.supersession_pairs;`. Every multi-statement section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
- [x] 3.2 `internal/migrate/testdata/console-reader-columns.txt`: the view's five columns, `effective_at`, `ended_provision_id`, `plan_ladder_id`, `pool_id`, `successor_provision_id`, each marked `read`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` and checked in the diff to be those five lines only.
- [x] 3.3 `docs/models/plan-ladders-transitions.md`, "Where this lives": a row for `core.supersession_pairs` (the one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason`; `00033_supersession_pairs.sql`), and the `core.grant_replacements` row says that it reads its pairs from that view (`00023_grant_resumption.sql`; `00033_supersession_pairs.sql`).
- [x] 3.4 Tests, `internal/entitlements/supersession_pairs_test.go` (package `entitlements_test`, over `newPlusProWorld`, in the world's rolled-back transaction):
- `TestSupersessionPairs_NameBothProvisions`: a subscription confers Plus (`conferSub`), an operator's grant of Pro is conferred over it; the view has exactly one row in the pool, on Wiki, with the subscription's provision ended, the grant's provision as successor and the conferral's instant. A failure reads `pairs = %+v, want one on Wiki from the subscription's provision to the grant's`.
- `TestSupersessionPairs_ARestorationIsNotAPair`: a grant of Plus whose `valid_until` has passed (`grantOpts{validUntil: p.now.Add(-time.Hour)}`), conferred and expired (`p.expire(g)`), so the floor restores Free in the same transaction by the same actor (`system`, no id, at `now()`, the one path where `from_rank` alone decides); no row names the default's provision as successor. Its precondition is `p.holdsLadder(p.wiki) == p.free`. A revocation cannot test this, because its `end` row is written by the operator and its floor row by the system, so the two never pair whether or not the view reads `from_rank`. A failure reads `the floor's restoration is paired with the expired grant's end`.
- `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`: a product that is a tier of Wiki and of a second ladder, Docs, conferred from a subscription, then a grant of Pro (Wiki only) over it; the view has a row on Wiki and none on Docs. A failure reads `a pair on Docs, where the grant wrote no rung`.
- `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`: a product that is a tier of Wiki and of a second ladder, Docs, conferred from a subscription, then a grant of Pro (Wiki only) over it; the view has a row on Wiki and none on Docs. Its preconditions are that Docs holds Plus before the grant and nothing after it. A failure reads `a pair on Docs, where the grant wrote no rung`.
- `TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant`: an operator's grants of Plus, Pro over it and Max over Pro, so one actor writes every row; the view has exactly the pairs Plus to Pro and Pro to Max. A failure reads `an end row is paired with a typed row written at another instant`.
- `TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral`: a grant of Plus expired at `now()` (system, the floor restores Free), then a subscription confers Pro over the restored Free at `now()` (webhook); no pair names the expired grant's provision. Its precondition is `p.holdsLadder(p.wiki) == p.free`. A failure reads `the expiry's end is paired with the subscription's conferral`.
- `NameBothProvisions` also reads `core.grant_replacements` and finds no row in the pool, and `ARestorationIsNotAPair` finds no replacement of the expired grant. No break separates the instant from the pair in `NameBothProvisions`: the join makes it equal on both rows. No break of `WHERE ep.grant_id IS NOT NULL` is caught either: with a subscription's ended provision, `ng.extends_grant_id IS DISTINCT FROM ep.grant_id` compares NULL with NULL and already refuses the row, so the clause matters only to an extension grant conferred over a subscription's provision, which no test builds.
- The six `TestGrantReplacements_*` tests, the `TestResumableGrants_*` tests and the `TestGrantWaits_*` tests in `internal/entitlements/grant_waits_test.go` pass unedited, which is the proof that `core.grant_replacements` keeps its rows; `TestConsoleReaderColumns` and `TestRuntimeRolePrivileges` pass with the view.
- [ ] 3.5 Breaks (command records, the migration file `internal/db/migrations/00033_supersession_pairs.sql`):
- [x] 3.5 Breaks (command records, the migration file `internal/db/migrations/00033_supersession_pairs.sql`):
- the view without `AND n.from_rank IS NOT NULL`: caught by `TestSupersessionPairs_ARestorationIsNotAPair` (`./internal/entitlements`), `want_new` `the floor's restoration is paired with the expired grant's end`;
- the view without `AND n.plan_ladder_id = e.plan_ladder_id`: caught by `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`, `want_new` `a pair on Docs, where the grant wrote no rung`;
- `core.grant_replacements` without `AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id`: caught by `TestGrantReplacements_AnExtensionIsNotAReplacement`, `want_new` `want none for an extension`;
- the `GRANT SELECT ON core.supersession_pairs` line removed: caught by `TestRuntimeRolePrivileges` (`./internal/db`), `want_new` `console_runtime cannot SELECT from core.supersession_pairs`;
- the view naming the provisions the other way round (`n.provision_id AS ended_provision_id`, `e.provision_id AS successor_provision_id`): caught by `TestSupersessionPairs_NameBothProvisions`, `want_new` `want one on Wiki from the subscription's provision to the grant's`;
- the view without `AND n.effective_at = e.effective_at`: caught by `TestSupersessionPairs_AChainOfActsPairsEachEndAtItsOwnInstant`, `want_new` `an end row is paired with a typed row written at another instant`;
- the view without `AND n.actor_type = e.actor_type`: caught by `TestSupersessionPairs_AnExpiryEndIsNotPairedWithTheSubscriptionsConferral`, `want_new` `the expiry's end is paired with the subscription's conferral`;
- the view selecting `e.reason` as a sixth column, which makes the reader script withhold it whole: caught by `TestConsoleReaderColumns` (`./internal/migrate`), `want_new` `core.supersession_pairs.pool_id is marked read, but console_reader does not read it`.
- [x] 3.6 `test/pin-timestamps.sql` walks base tables only (`information_schema.tables.table_type = 'BASE TABLE'`), with a sentence in its header saying why: `core.supersession_pairs` exposes `effective_at`, and the demo snapshot's pinning tried to update the view, which joins `core.pool_provision_transitions` to itself and so cannot be updated. No existing view carries one of the pinned columns, so the snapshot's pinned values do not change.
## 4. The reconcile leaves a ladder alone while another plan holds it
+11 -6
View File
@@ -16,7 +16,9 @@
-- per column would break the order between columns, putting a payment
-- before its invoice's issue.
-- Runs with session_replication_role = replica so updated_at triggers stay
-- quiet. A table without a primary key gets the base instant alone.
-- quiet. A table without a primary key gets the base instant alone. Views
-- are skipped: their rows are their tables' rows, already pinned, and a view
-- over several tables cannot be updated.
DO $$
DECLARE
r record;
@@ -24,11 +26,14 @@ DECLARE
rowexpr text;
BEGIN
FOR r IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'goose')
AND data_type LIKE 'timestamp%'
AND column_name IN ('created_at', 'updated_at', 'joined_at', 'effective_at', 'activated_at', 'valid_from', 'last_login_at', 'observed_at')
SELECT c.table_schema, c.table_name, c.column_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE t.table_type = 'BASE TABLE'
AND c.table_schema NOT IN ('pg_catalog', 'information_schema', 'goose')
AND c.data_type LIKE 'timestamp%'
AND c.column_name IN ('created_at', 'updated_at', 'joined_at', 'effective_at', 'activated_at', 'valid_from', 'last_login_at', 'observed_at')
LOOP
-- The primary key as one comparable text value, once unqualified (for
-- the ranking subquery) and once qualified (for the row it updates).