@consiliency/agent-board-schema 1.23.0 → 1.25.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 +12 -0
- package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
- package/migrations/20260906000100_agent_board_enroll_resumption.sql +749 -0
- package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -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/tests/agent_board_atomic_fleet_onboarding.test.sql +124 -112
- package/tests/agent_board_claimkey.test.sql +25 -3
- package/tests/agent_board_consent_emission.test.sql +17 -13
- package/tests/agent_board_consent_gating.test.sql +55 -11
- package/tests/agent_board_delegation_policies.test.sql +3 -1
- package/tests/agent_board_enroll_resumption.test.sql +412 -0
- package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
- package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
- package/tests/agent_board_mint_harden.test.sql +39 -4
- package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
- package/tests/agent_board_rejectsem.test.sql +1 -1
- package/tests/agent_board_rolereset.test.sql +23 -2
- package/tests/agent_board_rowid.test.sql +24 -13
- package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
- package/tests/agent_board_signed_authority_provenance.test.sql +454 -0
- package/tests/agent_board_source_strip_helper.test.sql +285 -0
- package/tests/agent_board_wake_recovery.test.sql +58 -40
- package/tests/catalog/p_metadata_parameters.json +689 -0
- package/tests/fixtures/test-prereqs.sql +313 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +3 -3
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
-- #247 — signed provenance for the authority-granting writes.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS CLOSES. `grant_board_role` and `set_board_authority_ceiling` are the two
|
|
4
|
+
-- authority-granting writes in the bundle, and neither could carry signing provenance in
|
|
5
|
+
-- any form. The client's `attachSigningMetadata` merges its evidence into `p_metadata`,
|
|
6
|
+
-- and neither function declares that parameter (verified against their NEWEST definitions,
|
|
7
|
+
-- 20260802000100 and 20260901000110 — not their first). Adding them to the client's signed
|
|
8
|
+
-- set would have attached an undeclared argument: a PostgREST signature mismatch, not a
|
|
9
|
+
-- silent no-op. So closing this is a schema change, not a client-list change.
|
|
10
|
+
--
|
|
11
|
+
-- MECHANISM (ratified 2026-08-12). These two get dedicated `signed_*` wrappers that call
|
|
12
|
+
-- `_record_signed_write_evidence` and write a real, queryable `signature_evidence` row —
|
|
13
|
+
-- the same mechanism as the five existing message/task signed writes, whose shape they
|
|
14
|
+
-- follow rather than inventing a new one. The alternative (`p_metadata.signed_write` on
|
|
15
|
+
-- the written row) records only that a client CLAIMED to sign; for "who granted this role,
|
|
16
|
+
-- and can we prove it", only an evidence row answers the question.
|
|
17
|
+
--
|
|
18
|
+
-- PROVENANCE IS DERIVED, NEVER ASSERTED. `_signed_write_actor_id` takes the actor from the
|
|
19
|
+
-- gateway's verified decision and raises `actor_mismatch` (42501) when a payload names a
|
|
20
|
+
-- different principal. That resolved actor — not `p_payload ->> 'p_grantor_agent_id'` — is
|
|
21
|
+
-- what the wrapper passes to the inner function AND what the evidence row names, because
|
|
22
|
+
-- the newest `_record_signed_write_evidence` (20260710000000) reads its actor solely from
|
|
23
|
+
-- `p_gateway_decision ->> 'agent_id'`. An evidence row recording the caller-supplied
|
|
24
|
+
-- grantor would be worse than no evidence row, because it looks like proof.
|
|
25
|
+
--
|
|
26
|
+
-- WHY set_board_authority_ceiling GAINS A PARAMETER. Its owner gate reads
|
|
27
|
+
-- `current_agent_id()` and nothing else, so it can only ever succeed for an authenticated
|
|
28
|
+
-- session. The `signed_*` layer is service_role-only BY DESIGN: 20260812000100 (#118)
|
|
29
|
+
-- revoked `_record_signed_write_evidence` from `authenticated` precisely because it mints
|
|
30
|
+
-- attestation rows from caller-supplied fields, so granting a `signed_*` wrapper to
|
|
31
|
+
-- `authenticated` would reopen that forge hole. A service_role-only wrapper therefore
|
|
32
|
+
-- cannot satisfy a `current_agent_id()`-only gate, and the bundle's existing delegation
|
|
33
|
+
-- mechanism is `_resolved_actor_agent_id` — exactly the shape `grant_board_role` already
|
|
34
|
+
-- has. Hence the new `p_actor_agent_id`.
|
|
35
|
+
--
|
|
36
|
+
-- D4 — THIS CHANGES NO UNSIGNED CALL. `p_actor_agent_id` defaults to null, and when it is
|
|
37
|
+
-- null the setter takes the pre-#247 path verbatim (`v_actor := current_agent_id()`), for
|
|
38
|
+
-- EVERY role. The resolver engages only when a caller supplies the argument. Under
|
|
39
|
+
-- `authenticated` the resolver additionally requires the supplied id to equal
|
|
40
|
+
-- `current_agent_id()` (42501 otherwise), so the new parameter cannot be used to act as
|
|
41
|
+
-- someone else. `create or replace` cannot add a parameter — it would leave a 2-arg and a
|
|
42
|
+
-- 3-arg overload live simultaneously and make a named-argument PostgREST call ambiguous —
|
|
43
|
+
-- so the setter is DROPped and recreated, with its `revoke`, its `grant` and its `comment`
|
|
44
|
+
-- restated in this same migration (a DROP discards the ACL and the COMMENT, and a
|
|
45
|
+
-- recreated SECURITY DEFINER function grants EXECUTE to PUBLIC by default).
|
|
46
|
+
--
|
|
47
|
+
-- EVIDENCE SHAPE. An authority write has no canonical source row to point at: the
|
|
48
|
+
-- `signature_evidence` source columns are message / task_event / artifact /
|
|
49
|
+
-- delivery_recipient. `source_kind = 'external'` is the constraint's existing shape for
|
|
50
|
+
-- "no internal source row, identified by a ref", and 20260718000300 already records
|
|
51
|
+
-- signed-write preflight evidence that way. The ref is synthesized SERVER-side from the
|
|
52
|
+
-- resolved actor and the board and FORCED over any caller-supplied value — a caller ref is
|
|
53
|
+
-- an assertion, a derived one is evidence. No table DDL is required.
|
|
54
|
+
|
|
55
|
+
-- 1. set_board_authority_ceiling — delegable actor, so a service_role gateway can carry a
|
|
56
|
+
-- signed grant on an owner's behalf. Everything else is verbatim from 20260901000110.
|
|
57
|
+
drop function if exists agent_board.set_board_authority_ceiling(uuid, text);
|
|
58
|
+
|
|
59
|
+
create or replace function agent_board.set_board_authority_ceiling(
|
|
60
|
+
p_board_id uuid,
|
|
61
|
+
p_max_grantable_role text,
|
|
62
|
+
p_actor_agent_id uuid default null
|
|
63
|
+
)
|
|
64
|
+
returns table(
|
|
65
|
+
ceiling_board_id uuid,
|
|
66
|
+
ceiling_max_grantable_role text
|
|
67
|
+
)
|
|
68
|
+
language plpgsql
|
|
69
|
+
security definer
|
|
70
|
+
set search_path = agent_board, public
|
|
71
|
+
as $$
|
|
72
|
+
declare
|
|
73
|
+
v_actor uuid;
|
|
74
|
+
begin
|
|
75
|
+
if p_max_grantable_role not in ('owner', 'admin', 'member', 'observer') then
|
|
76
|
+
raise exception 'max_grantable_role must be one of owner/admin/member/observer' using errcode = '22023';
|
|
77
|
+
end if;
|
|
78
|
+
|
|
79
|
+
-- Same board-keyed advisory lock every ceiling READER for authorization takes.
|
|
80
|
+
-- Held to commit, it makes the insert of a first ceiling row serialize against a
|
|
81
|
+
-- concurrent authorization that would otherwise observe the pre-insert ABSENCE and
|
|
82
|
+
-- pass as default-owner. Taken before the owner gate so the gate and the write are
|
|
83
|
+
-- one critical section.
|
|
84
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
85
|
+
|
|
86
|
+
-- #247. Actor resolution, NOT a widening of the gate. With p_actor_agent_id omitted this
|
|
87
|
+
-- is the pre-#247 expression for every role, so no existing caller changes behaviour
|
|
88
|
+
-- (D4). When it IS supplied, _resolved_actor_agent_id binds it: service_role takes the
|
|
89
|
+
-- argument (this is the signed_* gateway path, where the argument is the gateway-VERIFIED
|
|
90
|
+
-- signer, never a raw caller claim), while authenticated still resolves to
|
|
91
|
+
-- current_agent_id() and raises 42501 if the supplied id differs. The owner gate below is
|
|
92
|
+
-- untouched and remains the only thing that authorizes the write.
|
|
93
|
+
if p_actor_agent_id is null then
|
|
94
|
+
v_actor := agent_board.current_agent_id();
|
|
95
|
+
else
|
|
96
|
+
v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
97
|
+
end if;
|
|
98
|
+
|
|
99
|
+
if v_actor is null
|
|
100
|
+
or not exists (
|
|
101
|
+
select 1
|
|
102
|
+
from agent_board.board_memberships bm
|
|
103
|
+
where bm.board_id = p_board_id
|
|
104
|
+
and bm.agent_id = v_actor
|
|
105
|
+
and bm.role = 'owner'
|
|
106
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
107
|
+
) then
|
|
108
|
+
raise exception 'only a board owner may set the board authority ceiling' using errcode = '42501';
|
|
109
|
+
end if;
|
|
110
|
+
|
|
111
|
+
insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
|
|
112
|
+
values (p_board_id, p_max_grantable_role)
|
|
113
|
+
on conflict (board_id) do update
|
|
114
|
+
set max_grantable_role = excluded.max_grantable_role,
|
|
115
|
+
updated_at = now();
|
|
116
|
+
|
|
117
|
+
return query
|
|
118
|
+
select p_board_id, p_max_grantable_role;
|
|
119
|
+
end;
|
|
120
|
+
$$;
|
|
121
|
+
|
|
122
|
+
comment on function agent_board.set_board_authority_ceiling(uuid, text, uuid) is
|
|
123
|
+
'Owner-gated setter for a board''s authority ceiling: takes the board-keyed _lock_board_authority_ceiling advisory lock, then upserts board_authority_ceilings only when the resolved actor is an owner of the board (else raises 42501). The actor is current_agent_id() when p_actor_agent_id is omitted — the pre-#247 behaviour for every role — and _resolved_actor_agent_id(p_actor_agent_id) when it is supplied, which lets the service_role-only signed_set_board_authority_ceiling gateway act for the gateway-verified signer while still forcing an authenticated caller to match current_agent_id(). Leaves _grant_within_ceiling semantics unchanged.';
|
|
124
|
+
|
|
125
|
+
revoke all on function agent_board.set_board_authority_ceiling(uuid, text, uuid) from public, anon;
|
|
126
|
+
grant execute on function agent_board.set_board_authority_ceiling(uuid, text, uuid) to authenticated, service_role;
|
|
127
|
+
|
|
128
|
+
-- 2. signed_grant_board_role — evidence-recording wrapper over the live grant chokepoint.
|
|
129
|
+
create or replace function agent_board.signed_grant_board_role(
|
|
130
|
+
p_payload jsonb,
|
|
131
|
+
p_gateway_decision jsonb
|
|
132
|
+
)
|
|
133
|
+
returns table(
|
|
134
|
+
board_id uuid,
|
|
135
|
+
agent_id uuid,
|
|
136
|
+
role text,
|
|
137
|
+
signature_evidence_id uuid,
|
|
138
|
+
verification_status text,
|
|
139
|
+
updated_at timestamptz
|
|
140
|
+
)
|
|
141
|
+
language plpgsql
|
|
142
|
+
security definer
|
|
143
|
+
set search_path = agent_board, public
|
|
144
|
+
as $$
|
|
145
|
+
declare
|
|
146
|
+
v_row record;
|
|
147
|
+
v_actor uuid;
|
|
148
|
+
v_evidence_id uuid;
|
|
149
|
+
v_board uuid := (p_payload ->> 'p_board_id')::uuid;
|
|
150
|
+
v_grantee uuid := (p_payload ->> 'p_agent_id')::uuid;
|
|
151
|
+
begin
|
|
152
|
+
-- The grantor is the gateway-verified signer. _signed_write_actor_id raises 42501 when
|
|
153
|
+
-- the gateway names no actor, and raises actor_mismatch when the payload names a
|
|
154
|
+
-- DIFFERENT one -- so a payload p_grantor_agent_id can never become the grantor. This
|
|
155
|
+
-- value is what grant_board_role binds (through its own _resolved_actor_agent_id) and
|
|
156
|
+
-- what the evidence row names; they cannot diverge.
|
|
157
|
+
v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_grantor_agent_id');
|
|
158
|
+
|
|
159
|
+
select *
|
|
160
|
+
into v_row
|
|
161
|
+
from agent_board.grant_board_role(
|
|
162
|
+
v_board,
|
|
163
|
+
v_grantee,
|
|
164
|
+
p_payload ->> 'p_grantee_role',
|
|
165
|
+
v_actor
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
v_evidence_id := agent_board._record_signed_write_evidence(
|
|
169
|
+
'external',
|
|
170
|
+
null,
|
|
171
|
+
'grant_board_role',
|
|
172
|
+
p_payload,
|
|
173
|
+
p_gateway_decision || jsonb_build_object(
|
|
174
|
+
'external_evidence_ref',
|
|
175
|
+
'board-authority:grant_board_role:' || v_board::text || ':' || v_grantee::text
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return query
|
|
180
|
+
select v_row.board_id, v_row.agent_id, v_row.role, v_evidence_id,
|
|
181
|
+
coalesce(p_gateway_decision ->> 'verification_status', 'unsigned'), v_row.updated_at;
|
|
182
|
+
end;
|
|
183
|
+
$$;
|
|
184
|
+
|
|
185
|
+
comment on function agent_board.signed_grant_board_role(jsonb, jsonb) is
|
|
186
|
+
'#247 signed-write gateway wrapper for grant_board_role. Resolves the grantor with _signed_write_actor_id (gateway actor required; a payload p_grantor_agent_id that differs raises actor_mismatch 42501), passes THAT principal to grant_board_role so the bound grantor and the recorded one cannot diverge, and records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref naming the board and grantee. Adds no authority: grant_board_role''s membership, ceiling and TOCTOU gates are untouched. service_role only, like every other signed_* wrapper (#118).';
|
|
187
|
+
|
|
188
|
+
-- 3. signed_set_board_authority_ceiling — evidence-recording wrapper over the ceiling setter.
|
|
189
|
+
create or replace function agent_board.signed_set_board_authority_ceiling(
|
|
190
|
+
p_payload jsonb,
|
|
191
|
+
p_gateway_decision jsonb
|
|
192
|
+
)
|
|
193
|
+
returns table(
|
|
194
|
+
ceiling_board_id uuid,
|
|
195
|
+
ceiling_max_grantable_role text,
|
|
196
|
+
signature_evidence_id uuid,
|
|
197
|
+
verification_status text
|
|
198
|
+
)
|
|
199
|
+
language plpgsql
|
|
200
|
+
security definer
|
|
201
|
+
set search_path = agent_board, public
|
|
202
|
+
as $$
|
|
203
|
+
declare
|
|
204
|
+
v_row record;
|
|
205
|
+
v_actor uuid;
|
|
206
|
+
v_evidence_id uuid;
|
|
207
|
+
v_board uuid := (p_payload ->> 'p_board_id')::uuid;
|
|
208
|
+
begin
|
|
209
|
+
v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_actor_agent_id');
|
|
210
|
+
|
|
211
|
+
select *
|
|
212
|
+
into v_row
|
|
213
|
+
from agent_board.set_board_authority_ceiling(
|
|
214
|
+
v_board,
|
|
215
|
+
p_payload ->> 'p_max_grantable_role',
|
|
216
|
+
v_actor
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
v_evidence_id := agent_board._record_signed_write_evidence(
|
|
220
|
+
'external',
|
|
221
|
+
null,
|
|
222
|
+
'set_board_authority_ceiling',
|
|
223
|
+
p_payload,
|
|
224
|
+
p_gateway_decision || jsonb_build_object(
|
|
225
|
+
'external_evidence_ref',
|
|
226
|
+
'board-authority:set_board_authority_ceiling:' || v_board::text || ':' || v_actor::text
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return query
|
|
231
|
+
select v_row.ceiling_board_id, v_row.ceiling_max_grantable_role, v_evidence_id,
|
|
232
|
+
coalesce(p_gateway_decision ->> 'verification_status', 'unsigned');
|
|
233
|
+
end;
|
|
234
|
+
$$;
|
|
235
|
+
|
|
236
|
+
comment on function agent_board.signed_set_board_authority_ceiling(jsonb, jsonb) is
|
|
237
|
+
'#247 signed-write gateway wrapper for set_board_authority_ceiling. Resolves the actor with _signed_write_actor_id (gateway actor required; a differing payload p_actor_agent_id raises actor_mismatch 42501) and passes it as p_actor_agent_id, so the owner gate authorizes the SAME principal the evidence row names. Records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref. Adds no authority: the owner gate still rejects any actor that is not an active board owner. service_role only, like every other signed_* wrapper (#118).';
|
|
238
|
+
|
|
239
|
+
revoke all on function agent_board.signed_grant_board_role(jsonb, jsonb) from public, anon, authenticated;
|
|
240
|
+
revoke all on function agent_board.signed_set_board_authority_ceiling(jsonb, jsonb) from public, anon, authenticated;
|
|
241
|
+
|
|
242
|
+
grant execute on function agent_board.signed_grant_board_role(jsonb, jsonb) to service_role;
|
|
243
|
+
grant execute on function agent_board.signed_set_board_authority_ceiling(jsonb, jsonb) to service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.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.25.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": 125,
|
|
50
|
+
"aggregateSha256": "0ce03fcc8b0ae0113e9b6de2c8e8bc346cf735a86fcca0221ffc8103d604969f",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1146,6 +1146,33 @@
|
|
|
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"
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
"order": 123,
|
|
1161
|
+
"id": "20260906000100_agent_board_enroll_resumption",
|
|
1162
|
+
"filename": "20260906000100_agent_board_enroll_resumption.sql",
|
|
1163
|
+
"origin": "message-board-forward",
|
|
1164
|
+
"sourceRef": "message-board",
|
|
1165
|
+
"bytes": 41571,
|
|
1166
|
+
"sha256": "2aafad9df5da006039268d9559311f85fba634b6ec44d06a2f55fe3deedb09d8"
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
"order": 124,
|
|
1170
|
+
"id": "20260907000100_agent_board_signed_authority_provenance",
|
|
1171
|
+
"filename": "20260907000100_agent_board_signed_authority_provenance.sql",
|
|
1172
|
+
"origin": "message-board-forward",
|
|
1173
|
+
"sourceRef": "message-board",
|
|
1174
|
+
"bytes": 12633,
|
|
1175
|
+
"sha256": "21111e289e0bb70ac3236f5422476c0c51d0bca5a815c11dd4e48b6932cf7c6b"
|
|
1149
1176
|
}
|
|
1150
1177
|
],
|
|
1151
1178
|
"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
|
}
|