@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
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,18 @@
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"
495
+ },
496
+ {
497
+ "id": "20260906000100_agent_board_enroll_resumption",
498
+ "sha256": "2aafad9df5da006039268d9559311f85fba634b6ec44d06a2f55fe3deedb09d8"
499
+ },
500
+ {
501
+ "id": "20260907000100_agent_board_signed_authority_provenance",
502
+ "sha256": "21111e289e0bb70ac3236f5422476c0c51d0bca5a815c11dd4e48b6932cf7c6b"
491
503
  }
492
504
  ]
493
505
  }
@@ -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
+ $$;