@consiliency/agent-board-schema 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -1
- package/fixtures/already-applied.json +4 -0
- package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/check.js +23 -0
- package/src/index.js +20 -0
- package/src/plan.js +79 -5
- package/src/tooling.test.mjs +237 -4
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ agent-board-schema materialize --target <adopter>/supabase/migrations
|
|
|
26
26
|
|
|
27
27
|
# Read-only posture against a supplied migration-history snapshot.
|
|
28
28
|
agent-board-schema plan --history history.json
|
|
29
|
-
agent-board-schema check --history history.json # exit 0 exact / 10 behind / 11 ahead / 12 divergent
|
|
29
|
+
agent-board-schema check --history history.json # exit 0 exact / 10 behind / 11 ahead / 12 divergent / 14 collision
|
|
30
30
|
|
|
31
31
|
# Self-contained smoke: byte-exact + idempotent materialize.
|
|
32
32
|
agent-board-schema materialize --check-smoke
|
|
@@ -35,6 +35,80 @@ agent-board-schema manifest # print the compatibility manifest
|
|
|
35
35
|
agent-board-schema version
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Version collisions — build `history.json` from real rows, not from versions
|
|
39
|
+
|
|
40
|
+
**Supabase keys `supabase_migrations.schema_migrations` on the 14-digit version
|
|
41
|
+
prefix alone, not the name.** If you have independently authored a migration at a
|
|
42
|
+
version this bundle also uses, your database has that version recorded under *your*
|
|
43
|
+
name and the bundle's migration is **skipped silently** — no error, and
|
|
44
|
+
`schema_version` still reports the bundle's number, so the deploy looks successful.
|
|
45
|
+
|
|
46
|
+
This is not hypothetical. It happened at `20260809000100`, where an adopter's
|
|
47
|
+
`reconcile_panel_schema_drift` occupied the version used by
|
|
48
|
+
`agent_board_consent_gating` (message-board#217). 50 of this bundle's migrations share
|
|
49
|
+
the `000100` suffix, so if you also mint fixed-suffix timestamps the odds are far
|
|
50
|
+
above random.
|
|
51
|
+
|
|
52
|
+
**Build the history from version *and* name**, so `check` can see it. Let Postgres
|
|
53
|
+
do the encoding — a shell pipeline breaks on a name containing `:` or a quote, and
|
|
54
|
+
an empty result set produces a file that will not parse:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
psql "$DATABASE_URL" -At -c \
|
|
58
|
+
"select coalesce(json_agg(json_build_object('id', version || '_' || coalesce(name, ''))), '[]'::json)
|
|
59
|
+
from supabase_migrations.schema_migrations" \
|
|
60
|
+
| sed 's/^/{"applied":/; s/$/}/' > history.json
|
|
61
|
+
|
|
62
|
+
agent-board-schema check --history history.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A history built from **versions alone** cannot detect this — matching on the version
|
|
66
|
+
prefix is precisely the check that reports success on an affected database.
|
|
67
|
+
|
|
68
|
+
`check` exits **14** and names both sides:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
collision @20260809000100:
|
|
72
|
+
expected 20260809000100_agent_board_consent_gating
|
|
73
|
+
recorded as 20260809000100_reconcile_panel_schema_drift
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Exit 14 is deliberately not `behind` (10). "Behind" invites the fix of applying
|
|
77
|
+
again, which is the no-op that leaves the database wrong.
|
|
78
|
+
|
|
79
|
+
**Single-version discriminator**, if you only want to check the known case:
|
|
80
|
+
|
|
81
|
+
```sql
|
|
82
|
+
select name from supabase_migrations.schema_migrations where version = '20260809000100';
|
|
83
|
+
-- agent_board_consent_gating -> healthy
|
|
84
|
+
-- anything else -> this database skipped the bundle migration
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Remediation.** From bundle version 1.24.0 you do not need one:
|
|
88
|
+
`20260905000100_agent_board_consent_gating_reconcile` re-applies the only function
|
|
89
|
+
left stale by that skip (`join_board` — the other two are superseded by later
|
|
90
|
+
migrations that carry the gating forward). It is byte-identical on a healthy
|
|
91
|
+
database and self-healing on an affected one, with no `supabase migration repair`.
|
|
92
|
+
|
|
93
|
+
**A healed database stops reporting `collision`, even though the row is permanent.**
|
|
94
|
+
`schema_migrations` keys on `version`, its primary key, so the adopter's row at a
|
|
95
|
+
collided version can never be removed. `check` therefore reports on whether the
|
|
96
|
+
*remediating migration is applied*, not on whether the row exists — otherwise a
|
|
97
|
+
database that had done everything right would gate red forever, and an adopter
|
|
98
|
+
trained to ignore exit 14 would stop seeing genuine `behind` and `divergent`
|
|
99
|
+
states too. A remediated collision is reported under `collisionsRemediated` and both halves of
|
|
100
|
+
the pair are removed from the counts, so a healed database reports **`exact` / exit
|
|
101
|
+
0**. Removing them is the whole point: the bundle's migration can never be applied
|
|
102
|
+
there (its version is taken) and your row can never be removed, so left in they would
|
|
103
|
+
report `behind` forever — a different permanent red than the one being fixed.
|
|
104
|
+
|
|
105
|
+
A row whose `name` is NULL is treated as **indeterminate**, not as a collision: it
|
|
106
|
+
carries no name to tell apart from the bundle's own migration at that version. The
|
|
107
|
+
same applies to a history built from bare version strings.
|
|
108
|
+
|
|
109
|
+
For a collision at any *other* version there is no declared healer, so `check`
|
|
110
|
+
keeps reporting it and the fix is a `migration repair` on your side.
|
|
111
|
+
|
|
38
112
|
After applying the materialized migrations, the adopter records the install so the
|
|
39
113
|
authenticated compatibility RPC reports live:
|
|
40
114
|
|
|
@@ -488,6 +488,10 @@
|
|
|
488
488
|
{
|
|
489
489
|
"id": "20260904000100_agent_board_wake_recovery_surface",
|
|
490
490
|
"sha256": "6349f705751104c5d60ec6602ea534e7c8b7de5c4c6ad2d84dbda680a5267848"
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
"id": "20260905000100_agent_board_consent_gating_reconcile",
|
|
494
|
+
"sha256": "d5328a0006ecd1ec940c4fde5bbb43acf29b0997eefbea557009f792868e298b"
|
|
491
495
|
}
|
|
492
496
|
]
|
|
493
497
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
-- Reconcile join_board onto its consent-gated definition (message-board#217).
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS EXISTS. Supabase keys supabase_migrations.schema_migrations on the
|
|
4
|
+
-- 14-digit VERSION PREFIX alone, not the name. An adopter that independently
|
|
5
|
+
-- authored a migration at 20260809000100 therefore has that version recorded
|
|
6
|
+
-- under its own name, and the bundle's 20260809000100_agent_board_consent_gating
|
|
7
|
+
-- is SKIPPED SILENTLY -- no error, because that file is three
|
|
8
|
+
-- `create or replace function` statements. The database reaches schema_version 8
|
|
9
|
+
-- while retaining pre-consent authorization semantics, and the deploy reports
|
|
10
|
+
-- success. Found by Consiliency/consiliency-portal while adopting 1.22.0.
|
|
11
|
+
--
|
|
12
|
+
-- WHY join_board AND NOTHING ELSE. The skipped file redefines three functions,
|
|
13
|
+
-- but two of them are superseded by LATER migrations that carry the consent
|
|
14
|
+
-- gating forward: resolve_board_request by 20260819000100 and
|
|
15
|
+
-- update_board_membership by 20260813000100. join_board has no later definition
|
|
16
|
+
-- -- its newest is the skipped one -- so on an affected database it is still the
|
|
17
|
+
-- pre-consent 20260803000100 body. That single function is the entire live gap.
|
|
18
|
+
--
|
|
19
|
+
-- WHY NOT RENUMBER THE ORIGINAL. Renumbering looks safe because the migration is
|
|
20
|
+
-- idempotent, but idempotent is not order-independent. On a HEALTHY database the
|
|
21
|
+
-- renumbered file would be the only unapplied migration, so it would run LAST --
|
|
22
|
+
-- after 20260813000100 and 20260819000100 -- and re-apply the older
|
|
23
|
+
-- resolve_board_request and update_board_membership bodies over the newer ones.
|
|
24
|
+
-- That is a silent authorization ROLLBACK on every correctly-adopted database,
|
|
25
|
+
-- including production. A forward-only migration cannot do that, because it runs
|
|
26
|
+
-- last by construction and carries the newest definition of the only function it
|
|
27
|
+
-- touches.
|
|
28
|
+
--
|
|
29
|
+
-- EFFECT. Healthy database: byte-identical body, a true no-op. Affected database:
|
|
30
|
+
-- receives the consent gating it never got, with no `supabase migration repair`
|
|
31
|
+
-- and no operator intervention. `create or replace` preserves the existing ACL
|
|
32
|
+
-- and this migration adds no new grants, so the privilege surface is unchanged
|
|
33
|
+
-- either way.
|
|
34
|
+
|
|
35
|
+
create or replace function agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
36
|
+
RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
37
|
+
LANGUAGE plpgsql
|
|
38
|
+
SECURITY DEFINER
|
|
39
|
+
SET search_path TO 'agent_board', 'public'
|
|
40
|
+
as $$
|
|
41
|
+
declare
|
|
42
|
+
v_board agent_board.boards%rowtype;
|
|
43
|
+
v_existing_state text;
|
|
44
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
45
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
46
|
+
begin
|
|
47
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
48
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
49
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
50
|
+
end if;
|
|
51
|
+
|
|
52
|
+
if p_membership_state not in ('joined', 'subscribed') then
|
|
53
|
+
raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
|
|
54
|
+
end if;
|
|
55
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
56
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
57
|
+
end if;
|
|
58
|
+
if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
59
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
60
|
+
end if;
|
|
61
|
+
|
|
62
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
63
|
+
if v_board.id is null then
|
|
64
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
|
|
68
|
+
-- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
|
|
69
|
+
-- locking an active member out of their own private / invite_only board (P0002).
|
|
70
|
+
select bm.membership_state
|
|
71
|
+
into v_existing_state
|
|
72
|
+
from agent_board.board_memberships bm
|
|
73
|
+
where bm.board_id = p_board_id
|
|
74
|
+
and bm.agent_id = p_agent_id
|
|
75
|
+
order by
|
|
76
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
77
|
+
bm.created_at desc,
|
|
78
|
+
bm.id desc
|
|
79
|
+
limit 1;
|
|
80
|
+
|
|
81
|
+
-- #82: the CREATOR EXEMPTION IS REMOVED. Previously a board's creator could join ANY agent
|
|
82
|
+
-- to their board -- `v_actor <> v_board.created_by_agent_id` let them past this guard, and
|
|
83
|
+
-- the joinability check below only runs for self-joins, so nothing else stopped them. That
|
|
84
|
+
-- is the force-join hole. Creating a board does not entitle you to place other people in it.
|
|
85
|
+
--
|
|
86
|
+
-- Only the subject may join the subject. service_role (v_actor null) keeps the operator
|
|
87
|
+
-- carve-out, which is deliberate and maintainer-confirmed.
|
|
88
|
+
if v_actor is not null and v_actor <> p_agent_id then
|
|
89
|
+
raise exception 'board membership actor mismatch' using errcode = '42501';
|
|
90
|
+
end if;
|
|
91
|
+
|
|
92
|
+
-- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
|
|
93
|
+
-- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
|
|
94
|
+
-- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
|
|
95
|
+
-- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
|
|
96
|
+
-- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
|
|
97
|
+
-- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
|
|
98
|
+
--
|
|
99
|
+
-- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
|
|
100
|
+
-- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
|
|
101
|
+
-- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
|
|
102
|
+
-- created_at can surface 'left' as the total-order winner while standing is still removed
|
|
103
|
+
-- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
|
|
104
|
+
if v_actor is not null
|
|
105
|
+
and v_actor = p_agent_id
|
|
106
|
+
and v_board.created_by_agent_id <> v_actor
|
|
107
|
+
and not (
|
|
108
|
+
not (
|
|
109
|
+
exists (
|
|
110
|
+
select 1 from agent_board.board_memberships bm
|
|
111
|
+
where bm.board_id = p_board_id
|
|
112
|
+
and bm.agent_id = p_agent_id
|
|
113
|
+
and bm.membership_state = 'removed'
|
|
114
|
+
) and not exists (
|
|
115
|
+
select 1 from agent_board.board_memberships bm
|
|
116
|
+
where bm.board_id = p_board_id
|
|
117
|
+
and bm.agent_id = p_agent_id
|
|
118
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
and (
|
|
122
|
+
v_board.visibility = 'open'
|
|
123
|
+
-- #108: HOLDING AN INVITATION ADMITS THE SUBJECT ON ANY VISIBILITY, not only
|
|
124
|
+
-- invite_only. Previously `private` and `directory_hidden` were excluded outright, so
|
|
125
|
+
-- an invited subject had NO act available -- verified: a first-time invitee with no
|
|
126
|
+
-- prior refusal was blocked identically to a refused one. Gating makes consent
|
|
127
|
+
-- REQUIRED, so a board type where consent cannot be GIVEN is correct-but-unusable.
|
|
128
|
+
--
|
|
129
|
+
-- This is the narrowest widening that makes consent exercisable. It admits nobody new:
|
|
130
|
+
-- this whole guard is reached only when v_actor = p_agent_id (the actor-mismatch raise
|
|
131
|
+
-- above returns otherwise), so it is self-accept only. The board still decides who is
|
|
132
|
+
-- invited; the subject decides whether to accept.
|
|
133
|
+
or coalesce(v_existing_state, '') = 'invited'
|
|
134
|
+
or coalesce(v_existing_state, '') in ('joined', 'subscribed')
|
|
135
|
+
)
|
|
136
|
+
) then
|
|
137
|
+
raise exception 'board is not joinable by this agent' using errcode = 'P0002';
|
|
138
|
+
end if;
|
|
139
|
+
|
|
140
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
141
|
+
p_board_id,
|
|
142
|
+
p_agent_id,
|
|
143
|
+
p_membership_state,
|
|
144
|
+
coalesce(p_created_by_agent_id, p_agent_id),
|
|
145
|
+
p_metadata
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return query
|
|
149
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
150
|
+
end;
|
|
151
|
+
$$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.24.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 123,
|
|
50
|
+
"aggregateSha256": "f268991d3da80351c547d94249c48a5069a66267c3c1eaf580193bb47a89ff74",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1146,6 +1146,15 @@
|
|
|
1146
1146
|
"sourceRef": "message-board",
|
|
1147
1147
|
"bytes": 10795,
|
|
1148
1148
|
"sha256": "6349f705751104c5d60ec6602ea534e7c8b7de5c4c6ad2d84dbda680a5267848"
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
"order": 122,
|
|
1152
|
+
"id": "20260905000100_agent_board_consent_gating_reconcile",
|
|
1153
|
+
"filename": "20260905000100_agent_board_consent_gating_reconcile.sql",
|
|
1154
|
+
"origin": "message-board-forward",
|
|
1155
|
+
"sourceRef": "message-board",
|
|
1156
|
+
"bytes": 8152,
|
|
1157
|
+
"sha256": "d5328a0006ecd1ec940c4fde5bbb43acf29b0997eefbea557009f792868e298b"
|
|
1149
1158
|
}
|
|
1150
1159
|
],
|
|
1151
1160
|
"excludedBridge": {
|
package/src/check.js
CHANGED
|
@@ -9,8 +9,29 @@ export const EXIT = {
|
|
|
9
9
|
ahead: 11,
|
|
10
10
|
divergent: 12,
|
|
11
11
|
error: 13,
|
|
12
|
+
// 14, not a reuse of `behind`: a collision previously reported as behind, and
|
|
13
|
+
// "behind" invites the fix of applying again -- which is exactly the no-op
|
|
14
|
+
// that leaves the database wrong. See message-board#217.
|
|
15
|
+
collision: 14,
|
|
12
16
|
};
|
|
13
17
|
|
|
18
|
+
// Operator guidance per posture. It lives HERE, next to the posture definition,
|
|
19
|
+
// because the live-drift tool selected its message with a ternary over two named
|
|
20
|
+
// postures and an else -- so a `collision` printed "the deployment has migrations
|
|
21
|
+
// the bundle does not know about", which is the exact misdiagnosis this posture
|
|
22
|
+
// exists to prevent. A completeness test over EXIT keeps a new posture from ever
|
|
23
|
+
// again arriving without an action.
|
|
24
|
+
export const POSTURE_ACTIONS = Object.freeze({
|
|
25
|
+
exact: null, // nothing to do
|
|
26
|
+
behind:
|
|
27
|
+
'migrations in the bundle are NOT applied to this deployment (or were applied without being recorded). Reconcile before assuming the board is current.',
|
|
28
|
+
ahead: 'the deployment has migrations the bundle does not know about.',
|
|
29
|
+
divergent:
|
|
30
|
+
'an applied migration digest does NOT match the bundle — the deployment ran different bytes. Investigate before applying anything further.',
|
|
31
|
+
collision:
|
|
32
|
+
'a bundle migration was SKIPPED because its version is already recorded under a different name. The deployment is missing that DDL while reporting the bundle\'s schema version. Do NOT simply re-apply: the version is taken, so applying again is a no-op. See the collisions list for the expected and recorded ids, and message-board#217.',
|
|
33
|
+
});
|
|
34
|
+
|
|
14
35
|
export function check(history) {
|
|
15
36
|
const p = plan(history);
|
|
16
37
|
return {
|
|
@@ -19,5 +40,7 @@ export function check(history) {
|
|
|
19
40
|
exitCode: EXIT[p.posture] ?? EXIT.error,
|
|
20
41
|
counts: p.counts,
|
|
21
42
|
ahead: p.ahead,
|
|
43
|
+
collisions: p.collisions,
|
|
44
|
+
collisionsRemediated: p.collisionsRemediated,
|
|
22
45
|
};
|
|
23
46
|
}
|
package/src/index.js
CHANGED
|
@@ -39,6 +39,26 @@ export function loadOrderedMigrations() {
|
|
|
39
39
|
|
|
40
40
|
// Parse a supplied migration-history snapshot: an array of { id/version,
|
|
41
41
|
// sha256? } (applied migrations), or a plain array of version-id strings.
|
|
42
|
+
// Collisions this bundle can HEAL, and the migration that heals each one.
|
|
43
|
+
//
|
|
44
|
+
// A version collision is a PERMANENT fact about a database: Supabase's
|
|
45
|
+
// schema_migrations has `version` as its primary key, so an adopter's row at a
|
|
46
|
+
// bundle migration's version never disappears. Reporting `collision` on the
|
|
47
|
+
// mere presence of that row would therefore fail a fully-remediated database
|
|
48
|
+
// FOREVER, and an adopter forced to ignore exit 14 also stops seeing genuine
|
|
49
|
+
// `behind` and `divergent` states -- strictly worse than the problem it warns
|
|
50
|
+
// about. So a collision whose remediating migration is applied is reported as
|
|
51
|
+
// remediated and does not drive the posture.
|
|
52
|
+
//
|
|
53
|
+
// This map is a DECLARATION, not an enumeration: nothing in the catalog can
|
|
54
|
+
// derive "which migration heals which", so it is stated here and asserted in
|
|
55
|
+
// the tests -- both ids must exist in the bundle, and the healer must actually
|
|
56
|
+
// restate the skipped migration's function body.
|
|
57
|
+
export const COLLISION_REMEDIATIONS = Object.freeze({
|
|
58
|
+
'20260809000100_agent_board_consent_gating':
|
|
59
|
+
'20260905000100_agent_board_consent_gating_reconcile',
|
|
60
|
+
});
|
|
61
|
+
|
|
42
62
|
export function normalizeHistory(history) {
|
|
43
63
|
const rows = Array.isArray(history) ? history : history?.applied ?? [];
|
|
44
64
|
const map = new Map();
|
package/src/plan.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// history entries the adopter has that the bundle does not (ahead). Never
|
|
5
5
|
// touches the database.
|
|
6
6
|
|
|
7
|
-
import { loadOrderedMigrations, normalizeHistory } from './index.js';
|
|
7
|
+
import { COLLISION_REMEDIATIONS, loadOrderedMigrations, normalizeHistory } from './index.js';
|
|
8
8
|
|
|
9
9
|
export function plan(history) {
|
|
10
10
|
const migrations = loadOrderedMigrations();
|
|
@@ -21,6 +21,75 @@ export function plan(history) {
|
|
|
21
21
|
|
|
22
22
|
const ahead = [...applied.keys()].filter((id) => !bundleIds.has(id));
|
|
23
23
|
|
|
24
|
+
// VERSION COLLISIONS (message-board#217). Supabase keys
|
|
25
|
+
// supabase_migrations.schema_migrations on the 14-digit VERSION PREFIX alone,
|
|
26
|
+
// not the name. So an adopter who independently authored a migration at the
|
|
27
|
+
// same version has that version recorded under THEIR name, and the bundle's
|
|
28
|
+
// migration is skipped silently -- no error, and schema_version still reports
|
|
29
|
+
// the bundle's number, so the deploy looks successful.
|
|
30
|
+
//
|
|
31
|
+
// Such a database already shows up here as one `pending` plus one `ahead`, but
|
|
32
|
+
// that pair reads like a malformed history file rather than a skipped
|
|
33
|
+
// migration, and the natural reading ("we applied it, the tool just cannot see
|
|
34
|
+
// the digest") is the wrong one. Naming the collision explicitly is the
|
|
35
|
+
// difference between a puzzle and an instruction.
|
|
36
|
+
const versionOf = (id) => String(id).split('_')[0];
|
|
37
|
+
const aheadByVersion = new Map();
|
|
38
|
+
for (const id of ahead) {
|
|
39
|
+
const v = versionOf(id);
|
|
40
|
+
if (!aheadByVersion.has(v)) aheadByVersion.set(v, []);
|
|
41
|
+
aheadByVersion.get(v).push(id);
|
|
42
|
+
}
|
|
43
|
+
const allCollisions = perId
|
|
44
|
+
.filter((r) => r.action === 'pending')
|
|
45
|
+
.flatMap((r) => (aheadByVersion.get(versionOf(r.id)) ?? [])
|
|
46
|
+
// An ahead id with no NAME carries no evidence: it cannot be distinguished
|
|
47
|
+
// from the bundle's own migration at that version. Two shapes reach here --
|
|
48
|
+
// a bare version (normalizeHistory accepts plain version strings) and
|
|
49
|
+
// `<version>_` from a row whose `name` is NULL, which schema_migrations
|
|
50
|
+
// permits and the documented psql recipe renders exactly that way. A
|
|
51
|
+
// substring test for '_' admits the second and invents a collision from a
|
|
52
|
+
// legitimate history. Require an actual name.
|
|
53
|
+
.filter((recordedAs) => String(recordedAs).slice(versionOf(recordedAs).length + 1).length > 0)
|
|
54
|
+
.map((recordedAs) => ({
|
|
55
|
+
version: versionOf(r.id),
|
|
56
|
+
expected: r.id,
|
|
57
|
+
recordedAs,
|
|
58
|
+
remediatedBy: COLLISION_REMEDIATIONS[r.id] ?? null,
|
|
59
|
+
})));
|
|
60
|
+
|
|
61
|
+
// A collision is LIVE only while its healer is unapplied. Once healed the row
|
|
62
|
+
// remains in schema_migrations forever, but the DDL is present, so the
|
|
63
|
+
// database is correct and must be allowed to report a clean posture.
|
|
64
|
+
// The healer must be applied AND not divergent. A divergent digest means the
|
|
65
|
+
// database applied SOMETHING under that id which is not what this bundle ships,
|
|
66
|
+
// so nothing here can claim the skipped DDL was restored -- and claiming it
|
|
67
|
+
// would be the same false-success the collision detector exists to prevent.
|
|
68
|
+
// An UNKNOWN digest is not divergence: histories without digests are a
|
|
69
|
+
// supported shape, and demanding one would strand those adopters in the same
|
|
70
|
+
// permanent red this design just removed.
|
|
71
|
+
const healerDivergent = new Map(
|
|
72
|
+
perId.filter((r) => r.action === 'applied-divergent').map((r) => [r.id, true]),
|
|
73
|
+
);
|
|
74
|
+
const remediates = (c) =>
|
|
75
|
+
c.remediatedBy != null && applied.has(c.remediatedBy) && !healerDivergent.has(c.remediatedBy);
|
|
76
|
+
const collisions = allCollisions.filter((c) => !remediates(c));
|
|
77
|
+
const collisionsRemediated = allCollisions.filter(remediates);
|
|
78
|
+
|
|
79
|
+
// A remediated collision must stop affecting the posture ENTIRELY, not merely
|
|
80
|
+
// stop being called a collision. Both halves of the pair are permanent: the
|
|
81
|
+
// bundle's migration can never be applied on that database (its version is
|
|
82
|
+
// taken) so it would count `pending` forever, and the adopter's row can never
|
|
83
|
+
// be removed so it would count `ahead` forever. Left in, they gate the healed
|
|
84
|
+
// database at `behind`/10 in perpetuity -- a different permanent red than the
|
|
85
|
+
// one just fixed, which is why "not collision" was too weak a claim to make.
|
|
86
|
+
const remediatedExpected = new Set(collisionsRemediated.map((c) => c.expected));
|
|
87
|
+
const remediatedRecordedAs = new Set(collisionsRemediated.map((c) => c.recordedAs));
|
|
88
|
+
for (const r of perId) {
|
|
89
|
+
if (remediatedExpected.has(r.id) && r.action === 'pending') r.action = 'collision-remediated';
|
|
90
|
+
}
|
|
91
|
+
const effectiveAhead = ahead.filter((id) => !remediatedRecordedAs.has(id));
|
|
92
|
+
|
|
24
93
|
const counts = perId.reduce((acc, r) => {
|
|
25
94
|
acc[r.action] = (acc[r.action] || 0) + 1;
|
|
26
95
|
return acc;
|
|
@@ -29,16 +98,21 @@ export function plan(history) {
|
|
|
29
98
|
const divergent = perId.filter((r) => r.action === 'applied-divergent').length;
|
|
30
99
|
const pending = perId.filter((r) => r.action === 'pending').length;
|
|
31
100
|
let posture;
|
|
32
|
-
|
|
33
|
-
|
|
101
|
+
// Collision outranks every other posture: it is the only one where the
|
|
102
|
+
// database is silently missing bundle DDL while reporting success.
|
|
103
|
+
if (collisions.length > 0) posture = 'collision';
|
|
104
|
+
else if (divergent > 0) posture = 'divergent';
|
|
105
|
+
else if (effectiveAhead.length > 0 && pending === 0) posture = 'ahead';
|
|
34
106
|
else if (pending > 0) posture = 'behind';
|
|
35
107
|
else posture = 'exact';
|
|
36
108
|
|
|
37
109
|
return {
|
|
38
110
|
schema: 'agent_board_plan.v1',
|
|
39
111
|
posture,
|
|
40
|
-
counts: { ...counts, ahead:
|
|
41
|
-
ahead,
|
|
112
|
+
counts: { ...counts, ahead: effectiveAhead.length, collision: collisions.length, collisionRemediated: collisionsRemediated.length },
|
|
113
|
+
ahead: effectiveAhead,
|
|
114
|
+
collisions,
|
|
115
|
+
collisionsRemediated,
|
|
42
116
|
perId,
|
|
43
117
|
};
|
|
44
118
|
}
|
package/src/tooling.test.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { loadManifest, loadOrderedMigrations, sha256 } from './index.js';
|
|
6
6
|
import { materialize } from './materialize.js';
|
|
7
7
|
import { plan } from './plan.js';
|
|
8
|
-
import { check, EXIT } from './check.js';
|
|
8
|
+
import { check, EXIT, POSTURE_ACTIONS } from './check.js';
|
|
9
|
+
import { COLLISION_REMEDIATIONS } from './index.js';
|
|
9
10
|
|
|
10
11
|
const manifest = loadManifest();
|
|
11
12
|
const ordered = loadOrderedMigrations();
|
|
@@ -37,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
37
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
38
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
39
40
|
});
|
|
40
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 =
|
|
41
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 = 123)', () => {
|
|
41
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
42
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(123);
|
|
43
44
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
44
45
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
45
46
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -199,7 +200,239 @@ describe('plan / check postures + exit codes (SemVer contract)', () => {
|
|
|
199
200
|
expect(check({ applied }).exitCode).toBe(11);
|
|
200
201
|
});
|
|
201
202
|
it('exit-code map is a frozen SemVer surface', () => {
|
|
202
|
-
expect(EXIT).toEqual({ exact: 0, behind: 10, ahead: 11, divergent: 12, error: 13 });
|
|
203
|
+
expect(EXIT).toEqual({ exact: 0, behind: 10, ahead: 11, divergent: 12, error: 13, collision: 14 });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// VERSION COLLISION (message-board#217). Supabase keys schema_migrations on the
|
|
207
|
+
// 14-digit version prefix alone, so an adopter migration authored at a bundle
|
|
208
|
+
// migration's version makes the bundle's file skip SILENTLY -- no error, and
|
|
209
|
+
// schema_version still reports the bundle's number. The reported case is real:
|
|
210
|
+
// consiliency-portal recorded 20260809000100 as reconcile_panel_schema_drift.
|
|
211
|
+
// The adopter's own migration occupies the bundle's version, so the bundle's
|
|
212
|
+
// file was skipped. Two DISTINCT states matter, and conflating them is the
|
|
213
|
+
// defect a review seat caught: the collision ROW is permanent (Supabase keys
|
|
214
|
+
// schema_migrations on version, which is its primary key), so reporting on the
|
|
215
|
+
// row alone would fail a fully-healed database forever -- and an adopter forced
|
|
216
|
+
// to ignore exit 14 stops seeing genuine behind/divergent states too.
|
|
217
|
+
const portalHistory = (opts = {}) => ({
|
|
218
|
+
applied: [
|
|
219
|
+
...ordered
|
|
220
|
+
.filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
|
|
221
|
+
.filter((m) => opts.healed || m.id !== '20260905000100_agent_board_consent_gating_reconcile')
|
|
222
|
+
.map((m) => ({ id: m.id, sha256: m.sha256 })),
|
|
223
|
+
{ id: '20260809000100_reconcile_panel_schema_drift', sha256: 'aa' },
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('collision when an adopter migration occupies a bundle version and is unhealed', () => {
|
|
228
|
+
const c = check(portalHistory());
|
|
229
|
+
expect(c.posture).toBe('collision');
|
|
230
|
+
expect(c.exitCode).toBe(14);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('collision names BOTH sides, so the report is an instruction not a puzzle', () => {
|
|
234
|
+
expect(plan(portalHistory()).collisions).toEqual([
|
|
235
|
+
{
|
|
236
|
+
version: '20260809000100',
|
|
237
|
+
expected: '20260809000100_agent_board_consent_gating',
|
|
238
|
+
recordedAs: '20260809000100_reconcile_panel_schema_drift',
|
|
239
|
+
remediatedBy: '20260905000100_agent_board_consent_gating_reconcile',
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('collision outranks behind — "behind" invites re-applying, which is the no-op that leaves the DB wrong', () => {
|
|
245
|
+
const p = plan(portalHistory());
|
|
246
|
+
expect(p.perId.filter((r) => r.action === 'pending').map((r) => r.id)).toContain(
|
|
247
|
+
'20260809000100_agent_board_consent_gating',
|
|
248
|
+
);
|
|
249
|
+
expect(p.posture).toBe('collision');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('a HEALED database reports exact/0 — not merely "not collision"', () => {
|
|
253
|
+
// Asserting "not collision / not 14" was too weak and a review seat broke it:
|
|
254
|
+
// both halves of a remediated pair are PERMANENT (the bundle migration can
|
|
255
|
+
// never be applied on that database because its version is taken, and the
|
|
256
|
+
// adopter's row can never be removed), so leaving them in the counts gated
|
|
257
|
+
// the healed database at behind/10 forever — a different permanent red than
|
|
258
|
+
// the one being fixed. The claim is exact/0, so that is what is asserted.
|
|
259
|
+
const p = plan(portalHistory({ healed: true }));
|
|
260
|
+
expect(p.collisions).toEqual([]);
|
|
261
|
+
expect(p.collisionsRemediated.map((c) => c.expected)).toEqual([
|
|
262
|
+
'20260809000100_agent_board_consent_gating',
|
|
263
|
+
]);
|
|
264
|
+
expect(p.counts.pending ?? 0).toBe(0);
|
|
265
|
+
expect(p.ahead).toEqual([]);
|
|
266
|
+
expect(p.posture).toBe('exact');
|
|
267
|
+
expect(check(portalHistory({ healed: true })).exitCode).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('a NULL migration name is indeterminate, not a collision', () => {
|
|
271
|
+
// schema_migrations.name is nullable, and the documented psql recipe renders
|
|
272
|
+
// such a row as `<version>_`. A substring test for '_' called that a named
|
|
273
|
+
// row and invented a collision from a legitimate history.
|
|
274
|
+
const applied = [
|
|
275
|
+
...ordered
|
|
276
|
+
.filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
|
|
277
|
+
.map((m) => ({ id: m.id, sha256: m.sha256 })),
|
|
278
|
+
{ id: '20260809000100_', sha256: 'aa' },
|
|
279
|
+
];
|
|
280
|
+
const p = plan({ applied });
|
|
281
|
+
expect(p.collisions).toEqual([]);
|
|
282
|
+
expect(check({ applied }).exitCode).toBe(10); // behind, which is true: the DDL is absent
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('a DIVERGENT healer does not remediate — it is not the migration we shipped', () => {
|
|
286
|
+
// The database applied something under the healer's id that is not this
|
|
287
|
+
// bundle's bytes, so nothing here can claim the skipped DDL was restored.
|
|
288
|
+
// Posture already goes divergent, but reporting the collision as REMEDIATED
|
|
289
|
+
// would be the same false-success the detector exists to prevent.
|
|
290
|
+
const healer = '20260905000100_agent_board_consent_gating_reconcile';
|
|
291
|
+
const applied = [
|
|
292
|
+
...ordered
|
|
293
|
+
.filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
|
|
294
|
+
.map((m) => ({ id: m.id, sha256: m.id === healer ? 'deadbeef' : m.sha256 })),
|
|
295
|
+
{ id: '20260809000100_reconcile_panel_schema_drift', sha256: 'aa' },
|
|
296
|
+
];
|
|
297
|
+
const p = plan({ applied });
|
|
298
|
+
expect(p.collisionsRemediated).toEqual([]);
|
|
299
|
+
expect(p.collisions).toHaveLength(1);
|
|
300
|
+
expect(p.posture).toBe('collision');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('an UNKNOWN healer digest still remediates — digest-less histories are supported', () => {
|
|
304
|
+
// normalizeHistory accepts entries without a digest. Requiring one would
|
|
305
|
+
// strand those adopters in the permanent red this design removes.
|
|
306
|
+
const applied = [
|
|
307
|
+
...ordered.filter((m) => m.id !== '20260809000100_agent_board_consent_gating').map((m) => m.id),
|
|
308
|
+
'20260809000100_reconcile_panel_schema_drift',
|
|
309
|
+
];
|
|
310
|
+
const p = plan({ applied });
|
|
311
|
+
expect(p.collisionsRemediated).toHaveLength(1);
|
|
312
|
+
expect(p.collisions).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('the healer being present is what clears it — not merely being declared', () => {
|
|
316
|
+
// Same declaration, healer unapplied: still live. Without this the previous
|
|
317
|
+
// test would pass on a map lookup rather than on the database's actual state.
|
|
318
|
+
expect(plan(portalHistory()).collisionsRemediated).toEqual([]);
|
|
319
|
+
expect(plan(portalHistory()).collisions).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('a bare-version history is indeterminate, not 123 collisions', () => {
|
|
323
|
+
// normalizeHistory accepts plain version strings. Such an entry carries no
|
|
324
|
+
// name, so it cannot be told apart from the bundle's own migration at that
|
|
325
|
+
// version — reporting it as a collision turns a supported input shape into a
|
|
326
|
+
// wall of false positives.
|
|
327
|
+
const p = plan({ applied: ordered.map((m) => m.id.split('_')[0]) });
|
|
328
|
+
expect(p.collisions).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('an unrelated adopter migration is NOT a collision, even with a bundle migration pending', () => {
|
|
332
|
+
// The history MUST have something pending as well as something ahead. A first
|
|
333
|
+
// draft of this test used an exact history plus one extra entry, and a mutation
|
|
334
|
+
// that reported every ahead entry as a collision SURVIVED it: with nothing
|
|
335
|
+
// pending there is no cross-product to over-report, so the assertion passed
|
|
336
|
+
// while checking nothing. A negative assertion driven by a clean input proves
|
|
337
|
+
// nothing (CLAUDE.md rule 2).
|
|
338
|
+
const applied = [
|
|
339
|
+
...ordered
|
|
340
|
+
.filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
|
|
341
|
+
.map((m) => ({ id: m.id, sha256: m.sha256 })),
|
|
342
|
+
{ id: '99999999999999_extra', sha256: 'ff' },
|
|
343
|
+
];
|
|
344
|
+
const p = plan({ applied });
|
|
345
|
+
expect(p.perId.some((r) => r.action === 'pending')).toBe(true); // the input is not clean
|
|
346
|
+
expect(p.ahead).toContain('99999999999999_extra');
|
|
347
|
+
expect(p.collisions).toEqual([]);
|
|
348
|
+
expect(p.posture).toBe('behind');
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// OPERATOR GUIDANCE. Checks EXIT and POSTURE_ACTIONS agree with each other.
|
|
353
|
+
//
|
|
354
|
+
// This is NOT the completeness guard, and an earlier version of this comment
|
|
355
|
+
// claimed it was. EXIT is a SECOND hand list, not the catalog: a posture added to
|
|
356
|
+
// plan() without an EXIT entry passes everything here, falls through check()'s
|
|
357
|
+
// `EXIT[p.posture] ?? EXIT.error` to exit 13, and prints no action. The real
|
|
358
|
+
// catalog is the set of values plan() assigns, and scripts/check-posture-consumers.mjs
|
|
359
|
+
// reads it from there -- and also holds the live-drift consumer to using
|
|
360
|
+
// POSTURE_ACTIONS, which no test in this package can see. That script runs in
|
|
361
|
+
// `schema:verify`. What follows is the in-package half: the two tables stay
|
|
362
|
+
// consistent, and the collision action never tells an operator to re-apply.
|
|
363
|
+
describe('every posture has operator guidance', () => {
|
|
364
|
+
const postures = Object.keys(EXIT).filter((k) => k !== 'error');
|
|
365
|
+
|
|
366
|
+
it('EXIT and POSTURE_ACTIONS cover the same postures', () => {
|
|
367
|
+
expect(Object.keys(POSTURE_ACTIONS).sort()).toEqual(postures.sort());
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('every non-exact posture has a non-empty action', () => {
|
|
371
|
+
for (const posture of postures) {
|
|
372
|
+
if (posture === 'exact') {
|
|
373
|
+
expect(POSTURE_ACTIONS.exact).toBeNull(); // nothing to do is not a missing action
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
expect(typeof POSTURE_ACTIONS[posture]).toBe('string');
|
|
377
|
+
expect(POSTURE_ACTIONS[posture].length).toBeGreaterThan(20);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("the collision action does NOT tell an operator to re-apply", () => {
|
|
382
|
+
// Re-applying is the no-op that leaves the database wrong: the version is taken.
|
|
383
|
+
expect(POSTURE_ACTIONS.collision).toMatch(/no-op|not simply re-apply|Do NOT/i);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// COLLISION REMEDIATION (message-board#217). The reconcile migration's whole
|
|
388
|
+
// no-op-on-a-healthy-database property rests on its join_board body being
|
|
389
|
+
// byte-identical to the one in the migration an affected database skipped. The
|
|
390
|
+
// PR asserted that in prose and nothing checked it -- the exact shape this repo
|
|
391
|
+
// keeps getting caught by. The digest tests freeze each file's bytes, which is
|
|
392
|
+
// not the same claim: two files can both be frozen and still differ.
|
|
393
|
+
describe('collision remediation is real, not merely declared', () => {
|
|
394
|
+
const read = (id) =>
|
|
395
|
+
readFileSync(new URL(`../migrations/${id}.sql`, import.meta.url), 'utf8');
|
|
396
|
+
const joinBoardBlock = (sql) => {
|
|
397
|
+
const start = sql.search(/create or replace function agent_board\.join_board\b/i);
|
|
398
|
+
const rest = sql.slice(start + 10);
|
|
399
|
+
const next = rest.search(/create or replace function agent_board\./i);
|
|
400
|
+
return (next === -1 ? rest : rest.slice(0, next)).trimEnd();
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
it('every declared remediation names migrations that exist in the bundle', () => {
|
|
404
|
+
const ids = new Set(ordered.map((m) => m.id));
|
|
405
|
+
for (const [skipped, healer] of Object.entries(COLLISION_REMEDIATIONS)) {
|
|
406
|
+
expect(ids.has(skipped)).toBe(true);
|
|
407
|
+
expect(ids.has(healer)).toBe(true);
|
|
408
|
+
// The healer must come LATER, or it cannot run after the skip.
|
|
409
|
+
expect(healer > skipped).toBe(true);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('the healer restates the skipped function BYTE-IDENTICALLY, so a healthy DB is a true no-op', () => {
|
|
414
|
+
const skipped = joinBoardBlock(read('20260809000100_agent_board_consent_gating'));
|
|
415
|
+
const healer = joinBoardBlock(read('20260905000100_agent_board_consent_gating_reconcile'));
|
|
416
|
+
expect(healer.length).toBeGreaterThan(1000);
|
|
417
|
+
expect(healer).toBe(skipped);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('the healer touches ONLY the stale function, so it cannot clobber a later definition', () => {
|
|
421
|
+
// resolve_board_request and update_board_membership are superseded by
|
|
422
|
+
// 20260819000100 and 20260813000100. Restating them here would re-apply the
|
|
423
|
+
// older bodies over the newer ones on every healthy database.
|
|
424
|
+
const sql = read('20260905000100_agent_board_consent_gating_reconcile');
|
|
425
|
+
const defined = [...sql.matchAll(/create or replace function agent_board\.(\w+)/gi)].map((m) => m[1]);
|
|
426
|
+
expect(defined).toEqual(['join_board']);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('the healer adds no grant, revoke or comment — create-or-replace preserves the ACL', () => {
|
|
430
|
+
const sql = read('20260905000100_agent_board_consent_gating_reconcile')
|
|
431
|
+
.split('\n').filter((l) => !l.trimStart().startsWith('--')).join('\n');
|
|
432
|
+
expect(sql).not.toMatch(/\bgrant\b/i);
|
|
433
|
+
expect(sql).not.toMatch(/\brevoke\b/i);
|
|
434
|
+
expect(sql).not.toMatch(/comment\s+on/i);
|
|
435
|
+
expect(sql).not.toMatch(/drop\s+function/i);
|
|
203
436
|
});
|
|
204
437
|
});
|
|
205
438
|
|