@consiliency/agent-board-schema 1.22.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.
Files changed (65) hide show
  1. package/README.md +75 -1
  2. package/fixtures/already-applied.json +40 -0
  3. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  4. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  5. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  6. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  7. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  8. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  9. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  10. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  11. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  12. package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
  13. package/package.json +1 -1
  14. package/schema-manifest.json +93 -3
  15. package/src/check.js +23 -0
  16. package/src/index.js +20 -0
  17. package/src/plan.js +79 -5
  18. package/src/tooling.test.mjs +237 -4
  19. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  20. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  21. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  22. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  23. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  24. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  25. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  26. package/tests/agent_board_fedsession.test.sql +46 -2
  27. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  28. package/tests/agent_board_inbox_reads.test.sql +16 -19
  29. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  30. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  31. package/tests/agent_board_roomlink.test.sql +2 -2
  32. package/tests/agent_board_wake_recovery.test.sql +756 -0
  33. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  34. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  35. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  36. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  37. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  38. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  39. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  40. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  41. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  42. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  43. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  44. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  45. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  46. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  47. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  48. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  49. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  50. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  51. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  52. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  53. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  54. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  55. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  56. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  57. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  58. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  59. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  60. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  61. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  62. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  63. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  64. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  65. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,177 @@
1
+ -- Consiliency/message-board#193 -- enroll_federated_human skipped its board and
2
+ -- provider locking entirely when the provider row did not exist, then locked the
3
+ -- human owner and delegated. The delegate re-reads the provider under a FRESH READ
4
+ -- COMMITTED snapshot, so a provider inserted and committed in that gap was visible
5
+ -- to it and its default board was auto-joined while this transaction already held
6
+ -- auth.users:
7
+ --
8
+ -- portal fedsession (absent-provider path) holds owner -> wants board
9
+ -- onboard_federated_fleet_agent holds board -> wants owner
10
+ --
11
+ -- reproduced as `ERROR: deadlock detected`. This is the third instance of one
12
+ -- family (#153 machine<->domain, #189 ceiling-ABSENCE, this) plus #194: an
13
+ -- ordering guarantee that holds on the common path and escapes on an edge no
14
+ -- textual check can see. Here the escaping edge is ABSENCE -- there is no row to
15
+ -- lock, so the ordering simply does not happen.
16
+ --
17
+ -- THE FIX is to fail before acquiring anything, which is the only ordering-safe
18
+ -- answer when the thing to order on does not exist. The neighbouring case (a
19
+ -- concurrent NULL -> board designation) was already handled by 20260901000140 via
20
+ -- the snapshot/lock/compare; provider-absent-entirely was the case that escaped it.
21
+ --
22
+ -- BEHAVIOUR CHANGE IN THE RACE, written down rather than left to be discovered.
23
+ -- Pre-fix, a caller that lost a provider-creation race could OCCASIONALLY SUCCEED:
24
+ -- the delegate re-read the provider on a fresh READ COMMITTED snapshot and, if the
25
+ -- creation had committed in the gap, proceeded -- which is precisely the
26
+ -- interleaving that produced the deadlock. Post-fix that caller deterministically
27
+ -- fails ONCE, with the same 42501 a permanently unallowlisted issuer gets, and
28
+ -- succeeds on its next /portal-session call. That is the trade: an occasional
29
+ -- silent success bought with a cross-route cycle, exchanged for one deterministic
30
+ -- refusal.
31
+ --
32
+ -- WHY 42501 AND NOT A RETRYABLE 40001. No shipped caller distinguishes them.
33
+ -- Nothing under packages/*/src references 40001 or 40P01, and
34
+ -- AgentBoardClientError.retryable is set from HTTP status and only ever consumed by
35
+ -- the session-mint loop -- no code path maps a SQLSTATE to a retry. So the two
36
+ -- codes are operationally identical to every client that exists today, while 40001
37
+ -- would additionally instruct any FUTURE retry loop to spin forever on a genuinely
38
+ -- unallowlisted issuer. A serialization-based alternative (advisory-lock the
39
+ -- absence, re-read under it, let the race loser proceed) was built and reverted for
40
+ -- the same reason: it is machinery for a difference no caller can observe.
41
+
42
+ -- Recreated with CREATE OR REPLACE (ACL and COMMENT survive); grants and comment
43
+ -- restated below regardless, per the repo rule. Body is otherwise byte-identical to
44
+ -- 20260901000140.
45
+ --
46
+ -- WHAT THIS DOES NOT CHANGE. A permanently unallowlisted (issuer, audience) already
47
+ -- failed with 42501 -- from the delegate's step (4), after the owner had been
48
+ -- provisioned and locked. The verdict and the SQLSTATE are unchanged; only the
49
+ -- point at which they are reached moves, and with it the lock the transaction was
50
+ -- holding when it happened. tests/isolation/specs/fedsession_provider_absent_owner_lock.spec
51
+ -- pins exactly that: with a guard session holding the owner row, the pre-fix
52
+ -- function BLOCKS and the fixed one raises without waiting.
53
+
54
+ create or replace function agent_board.enroll_federated_human(
55
+ p_issuer text,
56
+ p_audience text,
57
+ p_subject text,
58
+ p_human_identity_claim jsonb,
59
+ p_display_name text default null
60
+ )
61
+ returns table(
62
+ agent_id uuid,
63
+ auth_user_id uuid,
64
+ handle text,
65
+ kind text,
66
+ status text,
67
+ provider_id text,
68
+ created_at timestamptz
69
+ )
70
+ language plpgsql
71
+ security definer
72
+ set search_path = agent_board, public
73
+ as $$
74
+ declare
75
+ v_provider_id agent_board.identity_providers.id%type;
76
+ v_default_board_id uuid;
77
+ v_locked_default_board_id uuid;
78
+ v_handle text;
79
+ v_uid uuid;
80
+ begin
81
+ if not agent_board._is_service_role() then
82
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
83
+ end if;
84
+ if p_subject is null then
85
+ raise exception 'verified subject is required' using errcode = '22023';
86
+ end if;
87
+
88
+ -- Snapshot the provider's auto-join target without a row lock so a non-null
89
+ -- target can be locked in canonical board -> provider order. Then lock the
90
+ -- provider row and require that the target did not change across the gap. A
91
+ -- concurrent NULL -> board designation is rejected for retry rather than
92
+ -- falling back to provider -> board and recreating the cross-route cycle.
93
+ select ip.id, ip.default_board_id
94
+ into v_provider_id, v_default_board_id
95
+ from agent_board.identity_providers ip
96
+ where ip.issuer = p_issuer
97
+ and ip.audience = p_audience
98
+ limit 1;
99
+
100
+ -- #193 PROVIDER-ABSENCE GATE, and it must come BEFORE the owner is provisioned
101
+ -- or locked. PostgreSQL cannot row-lock a row that does not exist, so an absent
102
+ -- provider left NOTHING to serialize on: this function used to skip the whole
103
+ -- board/provider ordering, lock the owner, and delegate -- and under READ
104
+ -- COMMITTED the delegate takes a FRESH snapshot, so a provider committed in that
105
+ -- gap became visible and its default board was auto-joined while this
106
+ -- transaction held auth.users. That is owner -> board, inverted against fleet
107
+ -- onboarding's board -> owner, and it deadlocked.
108
+ --
109
+ -- Rejecting here is a VALID serialization -- it orders this call before the
110
+ -- provider's creation, which is exactly what its own snapshot saw -- and it is
111
+ -- the same verdict _enroll_federated_human_after_revocation_gate step (4) would
112
+ -- have reached anyway for an allowlist miss, only now reached before any lock.
113
+ -- The message differs from the delegate's so a test can tell which gate fired.
114
+ -- NOTE: this is a permanent 42501, not a retryable 40001. A genuinely
115
+ -- unallowlisted issuer is a configuration error, and telling a caller to retry it
116
+ -- forever is worse than telling it no; the rare loser of a real provider-creation
117
+ -- race succeeds on its next /portal-session call.
118
+ if v_provider_id is null then
119
+ raise exception 'issuer/audience not in the identity-provider allowlist (rejected before any owner or board lock)'
120
+ using errcode = '42501';
121
+ end if;
122
+
123
+ if v_default_board_id is not null then
124
+ perform 1 from agent_board.boards b
125
+ where b.id = v_default_board_id
126
+ for update;
127
+ if not found then
128
+ raise exception 'federated human default board changed during enrollment'
129
+ using errcode = '40001';
130
+ end if;
131
+ end if;
132
+
133
+ -- Freeze the designation this transaction will delegate against. The provider row
134
+ -- is held FOR UPDATE through commit, and (issuer, audience) is UNIQUE, so the
135
+ -- delegate's own lookup cannot resolve to a different row or a changed
136
+ -- default_board_id than the one locked above.
137
+ select ip.default_board_id
138
+ into v_locked_default_board_id
139
+ from agent_board.identity_providers ip
140
+ where ip.id = v_provider_id
141
+ for update;
142
+ if not found or v_locked_default_board_id is distinct from v_default_board_id then
143
+ raise exception 'federated human default board changed during enrollment'
144
+ using errcode = '40001';
145
+ end if;
146
+
147
+ v_handle := 'fed-human-' || substr(
148
+ encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
149
+ 1, 32);
150
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
151
+
152
+ perform 1 from auth.users u where u.id = v_uid for update;
153
+ if not found then
154
+ raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
155
+ end if;
156
+ if exists (
157
+ select 1
158
+ from agent_board.fleet_human_revocations r
159
+ where r.issuer = p_issuer
160
+ and r.subject = p_subject
161
+ ) then
162
+ raise exception 'human identity is durably revoked' using errcode = '42501';
163
+ end if;
164
+
165
+ return query
166
+ select * from agent_board._enroll_federated_human_after_revocation_gate(
167
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
168
+ );
169
+ end;
170
+ $$;
171
+
172
+ comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
173
+ 'Service-role federated-human enrollment used by /portal-session. Requires an allowlisted (issuer, audience) BEFORE provisioning or locking anything (#193: an absent provider cannot be row-locked, so proceeding left the board/provider ordering unenforced and inverted board -> owner against fleet onboarding), then locks a non-null default board before its provider row, freezes that designation, holds the deterministic auth.users owner lock, rejects durable issuer/subject revocation, and delegates to the internal provisioner.';
174
+ revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
175
+ from public, anon, authenticated;
176
+ grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
177
+ to service_role;
@@ -0,0 +1,155 @@
1
+ -- WAKERECOVERY / message-board#191 — a gap-safe wake recovery surface.
2
+ --
3
+ -- THE DEFECT THIS CLOSES. A wake consumer that gates recovery on a monotonic
4
+ -- cursor silently drops a late-committing writer. `callback_outbox_events.seq`
5
+ -- (#187) is an identity column and identity sequences are NON-TRANSACTIONAL, so
6
+ -- `seq` is monotonic in ASSIGNMENT order, not COMMIT order: a transaction that
7
+ -- inserts early and commits late holds a seq BELOW a cursor the consumer has
8
+ -- already persisted, and `seq > my_cursor` never returns it. The committed
9
+ -- isolation probe `outbox_wake_ordering.spec` records exactly that
10
+ -- (`late_committer_holds_a_lower_seq_than_the_persisted_cursor = t`).
11
+ --
12
+ -- THE CLASS IS WIDER THAN `seq`. `created_at` / `updated_at` default to `now()`,
13
+ -- which is `transaction_timestamp()` — also assigned before commit, and worse:
14
+ -- a fan-out writes N rows with byte-identical timestamps, so a timestamp cursor
15
+ -- cannot even totally order what it DOES see. The unsafe class is therefore
16
+ -- "gates recovery on ANY value assigned before commit", not "gates on seq".
17
+ --
18
+ -- THE FIX IS TO STOP ASKING AN ORDERING QUESTION. Restart recovery does not need
19
+ -- "what is new since I last looked" (a cursor); it needs "what have I not dealt
20
+ -- with" (a STATE). A late-committing insert lands with `action_state = 'new'`, so
21
+ -- the moment it is visible it is in the set. There is no value to compare and
22
+ -- therefore nothing to skip. This is the same property that makes
23
+ -- `claim_callback_outbox_events` lossless (#187: "the claim path is lossless
24
+ -- because it partitions on STATUS, never on seq"), applied to the surface a wake
25
+ -- consumer actually fetches from.
26
+ --
27
+ -- WHY A VIEW AND NOT A FILTER IN THE CONSUMER. A predicate that lives only in a
28
+ -- TypeScript descriptor cannot be pinned: an isolation spec could only assert a
29
+ -- query the spec itself writes, and mutating the spec to make the spec fail
30
+ -- proves nothing. A view puts the predicate in pg_catalog where a probe can read
31
+ -- it and a mutation can break it. `pg_get_viewdef` is regenerated from the parse
32
+ -- tree, so comments and formatting are gone by construction — a class assertion
33
+ -- over a view definition cannot be defeated by prose the way a `prosrc` scanner
34
+ -- in this repo was once defeated by a COMMENT placed beside an inverted lock.
35
+ --
36
+ -- WHAT THIS MIGRATION DOES NOT DO. No producer changes. `inbox_items` gains no
37
+ -- column, no trigger, no policy. `callback_outbox_events` and its `seq` are
38
+ -- untouched — `seq` keeps exactly the meaning #187 shipped it with (deterministic
39
+ -- claim ordering and observability, never a recovery cursor).
40
+ -- `agent_board.schema_version` stays at 8: this adds no enforcement switch.
41
+ --
42
+ -- RECIPIENT SCOPING IS IN THIS VIEW, NOT IN THE CONSUMER'S FILTER. RLS on
43
+ -- `inbox_items` is NOT self-scoped: `agent_board_authenticated_select_inbox_items`
44
+ -- (20260713000000) admits a second disjunct --
45
+ -- board_id is not null AND visible_in_board_feed AND caller is a co-member
46
+ -- -- and `post_board_message` upserts every board delivery with BOTH visibility
47
+ -- flags true. So an unfiltered read of the unhandled set hands a caller its
48
+ -- CO-MEMBERS' items, which a triage consumer would then act on. Measured, not
49
+ -- reasoned: driving the real `post_board_message` path and reading this view as
50
+ -- agent A returned 1 row belonging to agent B before this predicate was added.
51
+ --
52
+ -- The scoping deliberately lives HERE and not in the runtime's fetch descriptor.
53
+ -- The FLEETNOTIFY contract states that a hint's filters are never authority; a
54
+ -- security boundary placed in `filters` is one a caller can drop or mistype, and
55
+ -- no catalog assertion could pin it. In the view it is server-side, applies to
56
+ -- every caller regardless of the descriptor, and is pinnable from pg_catalog --
57
+ -- the same argument that made this a view rather than a TypeScript predicate.
58
+ --
59
+ -- ACL NOTE. There is no DROP FUNCTION here, so the ACL/COMMENT-discard trap does
60
+ -- not arise. A `create or replace view` preserves an existing ACL, so the revoke
61
+ -- below is stated explicitly rather than implied by omitting a grant: re-running
62
+ -- this migration over an earlier copy of the view must CONVERGE, not inherit.
63
+ --
64
+ -- `service_role` is deliberately DENIED, which is why the grants are NOT a mirror
65
+ -- of `v_agent_inbox`'s. `service_role` holds BYPASSRLS and `current_agent_id()`
66
+ -- returns NULL for it, so a scoped view would hand it a silently EMPTY recovery
67
+ -- set -- an identity-less principal being told "nothing to recover" is precisely
68
+ -- the silent-drop failure this migration exists to close. Denying SELECT converts
69
+ -- that silence into a loud `permission denied`. This surface is defined relative
70
+ -- to the session's own agent identity; a principal without one has no answer here,
71
+ -- and must read `v_agent_inbox` with an explicit recipient filter instead.
72
+
73
+ -- Built on `v_agent_inbox`, not on `inbox_items`, so `visible_in_inbox = true` is
74
+ -- INHERITED rather than restated: one rule, one place.
75
+ --
76
+ -- WHAT `security_invoker = true` BUYS -- corrected AGAIN, from a full matrix.
77
+ -- Cross-agent exclusion is enforced by the `current_agent_id()` predicate below,
78
+ -- not by RLS. With that predicate deleted and the read taken as agent A, the four
79
+ -- cells are (co-member rows / stranger rows returned):
80
+ --
81
+ -- this view v_agent_inbox co-member stranger
82
+ -- invoker invoker 1 0
83
+ -- reset invoker 1 0
84
+ -- invoker reset 1 1
85
+ -- reset reset 1 1
86
+ --
87
+ -- THIS view's flag is inert in every cell. The bound is carried entirely by
88
+ -- `v_agent_inbox`'s flag: an inner `security_invoker` view re-anchors base-table
89
+ -- RLS to the real querying user through any outer definer view. An earlier
90
+ -- version of this paragraph said the bound held "on this view OR on
91
+ -- `v_agent_inbox`" -- attributing it to a flag that does not carry it. The flag
92
+ -- is still declared here because it becomes load-bearing the moment this view is
93
+ -- rebuilt directly on `inbox_items`, and `v_agent_inbox`'s flag is pinned as a
94
+ -- catalog-derived class over every agent_board view in
95
+ -- agent_board_inbox_reads.test.sql.
96
+ --
97
+ -- WHAT RECOVERY COMPLETENESS NOW DEPENDS ON, stated because this migration is what
98
+ -- makes it depend on them. `current_agent_id()` resolves `auth.uid()` to one
99
+ -- active agent. It carries no ORDER BY, but that is NOT a hazard: partial unique
100
+ -- indexes `idx_agent_board_agents_auth_user_id_unique` (auth_user_id IS NOT NULL)
101
+ -- and `..._active_unique` make two agents on one auth user impossible, so the
102
+ -- resolution is single-valued. (Checked -- a review seat reported "no unique
103
+ -- constraint on agents.auth_user_id", which is true of pg_constraint and false of
104
+ -- the schema: the guarantee is enforced by unique INDEXES.)
105
+ --
106
+ -- The real exposure is the other one: if the caller has NO active agent -- revoked,
107
+ -- or an operator session with no agent identity -- `current_agent_id()` returns
108
+ -- NULL and this view returns an EMPTY set rather than raising. Empty is
109
+ -- indistinguishable from "nothing to recover", which is the same silent-drop shape
110
+ -- the `service_role` revoke below closes, one layer up. A consumer that can be
111
+ -- identity-less must check its identity, not infer it from an empty recovery set.
112
+ --
113
+ -- There is deliberately NO `ORDER BY`. The unhandled set is a SET, not a cursor
114
+ -- stream. Ordering is presentation and belongs to the caller; putting one here
115
+ -- would invite keyset pagination over `created_at`, which is the exact defect
116
+ -- this migration closes.
117
+ --
118
+ -- The state set is `('new', 'restored', 'deferred')` VERBATIM from the existing
119
+ -- partial index `idx_agent_board_inbox_items_agent_pending`, so the index serves
120
+ -- this view. The pgTAP asserts that equality from the catalog on BOTH sides, so
121
+ -- editing either one alone fails.
122
+ create or replace view agent_board.v_agent_wake_recovery
123
+ with (security_invoker = true)
124
+ as
125
+ select
126
+ vi.inbox_item_id,
127
+ vi.delivery_recipient_id,
128
+ vi.recipient_agent_id,
129
+ vi.recipient_handle,
130
+ vi.recipient_display_name,
131
+ vi.board_id,
132
+ vi.board_slug,
133
+ vi.board_name,
134
+ vi.delivery_mode,
135
+ vi.source_kind,
136
+ vi.source_message_id,
137
+ vi.source_task_id,
138
+ vi.source_delegation_id,
139
+ vi.subject,
140
+ vi.preview,
141
+ vi.priority,
142
+ vi.action_state,
143
+ vi.visible_in_board_feed,
144
+ vi.fetch_descriptor,
145
+ vi.created_at,
146
+ vi.updated_at
147
+ from agent_board.v_agent_inbox vi
148
+ where vi.action_state in ('new', 'restored', 'deferred')
149
+ and vi.recipient_agent_id = agent_board.current_agent_id();
150
+
151
+ grant select on agent_board.v_agent_wake_recovery to authenticated;
152
+ revoke all on agent_board.v_agent_wake_recovery from service_role;
153
+
154
+ comment on view agent_board.v_agent_wake_recovery is
155
+ 'message-board#191 gap-safe wake recovery surface: the caller''s UNHANDLED inbox set (action_state in new/restored/deferred), partitioned on STATUS and carrying no cursor. Recovery must never gate on a pre-commit-assigned value (callback_outbox_events.seq, created_at, updated_at): those order ASSIGNMENT, not COMMIT, so a transaction that inserts early and commits late lands below an already-persisted cursor and is silently dropped. Scoped to the calling agent (current_agent_id()): RLS on inbox_items is NOT self-scoped -- it also admits co-members'' board-feed-visible items -- so the scoping is stated here rather than left to a caller''s filter. EXIT PATHS, verified against pg_catalog and not against the design doc: agent_board.update_inbox_item_state is the ONLY function that writes inbox_items.action_state. mark_read does NOT -- it writes read_receipts, so a read item is still unhandled. notification_ack is not a database function at all; it is a client-side alias that calls update_inbox_item_state with action_state = ''acknowledged''. DRAIN IN BOUNDED PASSES, NEVER "UNTIL EMPTY": ''deferred'' is itself an explicit ack write AND is a member of this set, so deferring an item acknowledges it and leaves it here -- a consumer that defers anything and loops until empty never terminates. Concurrent inserts can also outpace processing. A drain MUST bound its own passes and must not treat a non-empty set as a reason to keep looping. WHY THE ONE-WRITER CLAIM IS STRUCTURAL: inbox_items carries no user trigger, and authenticated/service_role hold SELECT only (no direct UPDATE), so nothing can move an item out of this set except the definer RPC. The second DML writer, _upsert_inbox_item, never touches action_state on conflict -- though it does overwrite visible_in_inbox, which would hide an item from this set with no ack if a caller ever passed false (no current caller does; the argument is positional and this is not pinned). security_invoker, so RLS on inbox_items still applies underneath the scoping.';
@@ -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.22.0",
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",
@@ -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.22.0",
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": 113,
50
- "aggregateSha256": "fb690d702458562b331aed192580f1568018d9d007368c8b9edd45d35d18ad96",
49
+ "migrationCount": 123,
50
+ "aggregateSha256": "f268991d3da80351c547d94249c48a5069a66267c3c1eaf580193bb47a89ff74",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1065,6 +1065,96 @@
1065
1065
  "sourceRef": "message-board",
1066
1066
  "bytes": 25051,
1067
1067
  "sha256": "8d24ec4d682969a5d9339aec0573fad65ee95993dce2f8dca214906549b5ebe8"
1068
+ },
1069
+ {
1070
+ "order": 113,
1071
+ "id": "20260901000100_agent_board_atomic_fleet_onboarding",
1072
+ "filename": "20260901000100_agent_board_atomic_fleet_onboarding.sql",
1073
+ "origin": "message-board-forward",
1074
+ "sourceRef": "message-board",
1075
+ "bytes": 64896,
1076
+ "sha256": "4cf07673b7c7da4ee13550e71d2bfe606b4b9c56971169e1ef61ac0d545ac6ef"
1077
+ },
1078
+ {
1079
+ "order": 114,
1080
+ "id": "20260901000110_agent_board_fleet_onboarding_ceiling_lock",
1081
+ "filename": "20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql",
1082
+ "origin": "message-board-forward",
1083
+ "sourceRef": "message-board",
1084
+ "bytes": 30096,
1085
+ "sha256": "310a32931562be6b4ad323b5e2926ebe459d6cd31bd6f601c7b862f354b5ce58"
1086
+ },
1087
+ {
1088
+ "order": 115,
1089
+ "id": "20260901000120_agent_board_human_revocation_evidence_completeness",
1090
+ "filename": "20260901000120_agent_board_human_revocation_evidence_completeness.sql",
1091
+ "origin": "message-board-forward",
1092
+ "sourceRef": "message-board",
1093
+ "bytes": 3077,
1094
+ "sha256": "e8ccb3605fb6dfa4e8dfbefbbb4328179a61aec00616c1abef27d6a09c2a913e"
1095
+ },
1096
+ {
1097
+ "order": 116,
1098
+ "id": "20260901000130_agent_board_fedsession_human_revocation_gate",
1099
+ "filename": "20260901000130_agent_board_fedsession_human_revocation_gate.sql",
1100
+ "origin": "message-board-forward",
1101
+ "sourceRef": "message-board",
1102
+ "bytes": 2919,
1103
+ "sha256": "f76d78973b87cc66c54ca0af4754358221ede19c8e0644df81a08e65dab0e89b"
1104
+ },
1105
+ {
1106
+ "order": 117,
1107
+ "id": "20260901000140_agent_board_fedsession_board_owner_lock_order",
1108
+ "filename": "20260901000140_agent_board_fedsession_board_owner_lock_order.sql",
1109
+ "origin": "message-board-forward",
1110
+ "sourceRef": "message-board",
1111
+ "bytes": 3824,
1112
+ "sha256": "2388528532e974ab27c35337f9b00263056c1b16d973b7b3a0cf8cebb7724cc4"
1113
+ },
1114
+ {
1115
+ "order": 118,
1116
+ "id": "20260902000100_agent_board_outbox_wake_ordering",
1117
+ "filename": "20260902000100_agent_board_outbox_wake_ordering.sql",
1118
+ "origin": "message-board-forward",
1119
+ "sourceRef": "message-board",
1120
+ "bytes": 8783,
1121
+ "sha256": "d3557acc29bf146862920fe903c3ac2e9e0e0317f3ca2134c5bb0835620fd2da"
1122
+ },
1123
+ {
1124
+ "order": 119,
1125
+ "id": "20260903000100_agent_board_enroll_identity_board_owner_lock_order",
1126
+ "filename": "20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql",
1127
+ "origin": "message-board-forward",
1128
+ "sourceRef": "message-board",
1129
+ "bytes": 29382,
1130
+ "sha256": "f8dc4ae6c5c6ea56183258402ab283cd424929b8e637984a59cf6ecd0685f681"
1131
+ },
1132
+ {
1133
+ "order": 120,
1134
+ "id": "20260903000200_agent_board_fedsession_provider_absence_gate",
1135
+ "filename": "20260903000200_agent_board_fedsession_provider_absence_gate.sql",
1136
+ "origin": "message-board-forward",
1137
+ "sourceRef": "message-board",
1138
+ "bytes": 8967,
1139
+ "sha256": "79c581011be386d0efd97db723d3aa07947ad7b996667878ed1387cdd106b000"
1140
+ },
1141
+ {
1142
+ "order": 121,
1143
+ "id": "20260904000100_agent_board_wake_recovery_surface",
1144
+ "filename": "20260904000100_agent_board_wake_recovery_surface.sql",
1145
+ "origin": "message-board-forward",
1146
+ "sourceRef": "message-board",
1147
+ "bytes": 10795,
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"
1068
1158
  }
1069
1159
  ],
1070
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
  }