@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.
Files changed (32) hide show
  1. package/README.md +75 -1
  2. package/fixtures/already-applied.json +12 -0
  3. package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
  4. package/migrations/20260906000100_agent_board_enroll_resumption.sql +749 -0
  5. package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
  6. package/package.json +1 -1
  7. package/schema-manifest.json +30 -3
  8. package/src/check.js +23 -0
  9. package/src/index.js +20 -0
  10. package/src/plan.js +79 -5
  11. package/src/tooling.test.mjs +237 -4
  12. package/tests/agent_board_atomic_fleet_onboarding.test.sql +124 -112
  13. package/tests/agent_board_claimkey.test.sql +25 -3
  14. package/tests/agent_board_consent_emission.test.sql +17 -13
  15. package/tests/agent_board_consent_gating.test.sql +55 -11
  16. package/tests/agent_board_delegation_policies.test.sql +3 -1
  17. package/tests/agent_board_enroll_resumption.test.sql +412 -0
  18. package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
  19. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
  20. package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
  21. package/tests/agent_board_mint_harden.test.sql +39 -4
  22. package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
  23. package/tests/agent_board_rejectsem.test.sql +1 -1
  24. package/tests/agent_board_rolereset.test.sql +23 -2
  25. package/tests/agent_board_rowid.test.sql +24 -13
  26. package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
  27. package/tests/agent_board_signed_authority_provenance.test.sql +454 -0
  28. package/tests/agent_board_source_strip_helper.test.sql +285 -0
  29. package/tests/agent_board_wake_recovery.test.sql +58 -40
  30. package/tests/catalog/p_metadata_parameters.json +689 -0
  31. package/tests/fixtures/test-prereqs.sql +313 -0
  32. 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.23.0",
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",
@@ -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.23.0",
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": 122,
50
- "aggregateSha256": "5bfa883d1264a31811a723ed8bd05b54227ae50c9266544d94830e8b3cdccfbf",
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
- if (divergent > 0) posture = 'divergent';
33
- else if (ahead.length > 0 && pending === 0) posture = 'ahead';
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: ahead.length },
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
  }