@consiliency/agent-board-schema 1.28.0 → 1.38.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 (43) hide show
  1. package/fixtures/already-applied.json +40 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
  10. package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
  11. package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
  12. package/package.json +1 -1
  13. package/schema-manifest.json +93 -3
  14. package/src/deployment-drift-script.test.mjs +424 -0
  15. package/src/deployment-drift.test.mjs +301 -0
  16. package/src/tooling.test.mjs +83 -2
  17. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  18. package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
  19. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  20. package/tests/agent_board_consent_substrate.test.sql +8 -1
  21. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  22. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  23. package/tests/agent_board_fedsession.test.sql +17 -2
  24. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  25. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  26. package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
  27. package/tests/agent_board_ledger_authority.test.sql +142 -71
  28. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  29. package/tests/agent_board_signed_write_enforced.test.sql +366 -0
  30. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  31. package/tests/agent_board_source_strip_helper.test.sql +256 -1
  32. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  33. package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
  34. package/tests/catalog/p_metadata_parameters.json +12 -0
  35. package/tests/fixtures/isolation-substrate.sql +1 -1
  36. package/tests/fixtures/test-prereqs.sql +442 -11
  37. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  38. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  39. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  40. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  41. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  42. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  43. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,398 @@
1
+ -- #317 — an invite must not weaken an active membership.
2
+ --
3
+ -- OBSERVED IN PRODUCTION. An agent that had been `joined` on a board since 2026-08-05 was
4
+ -- found with no visible membership. The report inferred the row had been DELETED and that
5
+ -- `v_board_memberships` filtered non-joined rows. Both inferences were wrong, and checking
6
+ -- them is what found this defect:
7
+ --
8
+ -- * the row was never deleted -- `service_role` sees it, and NO shipped function contains a
9
+ -- DELETE against board_memberships (verified across every pg_proc body);
10
+ -- * `v_board_memberships` has no WHERE clause at all. The empty result came from RLS
11
+ -- (`agent_id = current_agent_id()`), which shows a caller only its OWN rows in any state.
12
+ -- So the query could not distinguish "no membership" from "not mine to see";
13
+ -- * `board_membership_events` -- the audit table the report asked for -- already existed and
14
+ -- answered it in one query:
15
+ --
16
+ -- 2026-08-05 06:50:34 consent_link_redeem -> joined
17
+ -- 2026-08-20 12:31:31 invite_offer -> invited <-- the event
18
+ --
19
+ -- THE DEFECT. `_upsert_board_membership_state` set `membership_state = p_membership_state`
20
+ -- unconditionally, so inviting an agent who is ALREADY joined moves them back to `invited`.
21
+ -- The agent stops receiving board delivery; neither side gets an error; the board just looks
22
+ -- quiet. Absence of evidence renders identically to a negative result.
23
+ --
24
+ -- REPRODUCED before fixing, in a scratch database at this bundle:
25
+ --
26
+ -- 1. after join : joined
27
+ -- 2. after invite : invited <-- DOWNGRADED
28
+ --
29
+ -- WHY THIS IS NOT #109, WHICH ALREADY GUARDS THIS FUNCTION. #109 was the sink acting on the
30
+ -- WRONG ROW -- rejecting a pending request mutated the subject's ACTIVE membership -- and was
31
+ -- fixed by letting a caller name the row it validated (`p_membership_id`). That guard is
32
+ -- correct and untouched. This is the RIGHT row moved in the WRONG DIRECTION, which no
33
+ -- selection rule can catch. Filed as one defect class, fixed as another.
34
+ --
35
+ -- THE RULE. `invited` and `requested` are OFFERS: they describe a membership that does not yet
36
+ -- exist. Applying an offer to a membership that DOES exist (`joined`/`subscribed`) leaves the
37
+ -- state alone -- the offer is already satisfied and the stronger standing wins.
38
+ --
39
+ -- A no-op rather than an exception, deliberately. Invite is idempotent by design (the
40
+ -- MBCONSENT invite flow re-offers freely); raising here would break callers that succeed
41
+ -- today, converting a silent demotion into a loud crash rather than into correct behaviour.
42
+ --
43
+ -- SCOPE, stated as what is actually asserted rather than as a claim about "all callers".
44
+ -- The guard lives in the SHARED SINK, which is the only function that can MOVE AN EXISTING ROW
45
+ -- between states -- so every caller that reaches the state through it (invite_board_member,
46
+ -- join_board, request_join_board, resolve_board_request, update_board_membership, and
47
+ -- leave_board by delegation) inherits it.
48
+ --
49
+ -- "MOVE AN EXISTING ROW", not "assigns membership_state", and the distinction is load-bearing
50
+ -- rather than pedantic. An earlier draft of this sentence said the sink was the only function
51
+ -- that ASSIGNS the column, and the catalog falsifies that: the seeding writers assign it in an
52
+ -- INSERT COLUMN LIST -- `_redeem_unbounded_invite_for_agent` writes `preset_membership_state`
53
+ -- that way (20260909000100:988-993), as do create_board and the two bootstrap paths. What they
54
+ -- cannot do is move a row that already exists, which is what this defect is about and what the
55
+ -- test actually asserts ("the ONLY writer that assigns membership_state IN A SET CLAUSE"). The
56
+ -- test's label was right while this sentence was wrong; a review seat found the disagreement.
57
+ --
58
+ -- The claim is not asserted by reading this comment: agent_board_invite_no_downgrade.test.sql
59
+ -- enumerates from pg_proc, over stripped_body so prose cannot satisfy it, and pins THREE
60
+ -- separate containments, each verified by a mutation that made it fail by name:
61
+ --
62
+ -- * the set of functions that WRITE board_memberships is exactly six (a new writer is named);
63
+ -- * of those six, exactly one assigns membership_state IN A SET CLAUSE -- six distinct writer
64
+ -- spellings were
65
+ -- each confirmed to fail it BY NAME: `UPDATE ... SET`, `INSERT ... ON CONFLICT DO UPDATE
66
+ -- SET`, `MERGE INTO ... WHEN MATCHED THEN UPDATE SET`, an UNQUALIFIED `update
67
+ -- board_memberships` resolved by search_path, a statement assembled inside a string literal
68
+ -- and run by `EXECUTE`, and a `BEGIN ATOMIC` body (whose prosrc is empty);
69
+ -- * no user TRIGGER on the table, which could assign NEW.membership_state without any
70
+ -- function body naming it.
71
+ --
72
+ -- WHAT THOSE ASSERTIONS STILL DO NOT COVER, said plainly rather than left to be discovered:
73
+ -- the scan is `nspname = 'agent_board'`, so a writer in another schema is outside it; and it is
74
+ -- a regex over function source, so its completeness is the completeness of the pattern. The
75
+ -- pattern is taken verbatim from agent_board_receipt_subject_gate.test.sql rather than written
76
+ -- fresh, so the spellings that file already paid to learn are inherited instead of rediscovered.
77
+ --
78
+ -- THE FIRST DRAFT OF THAT CLASS ASSERTION WAS WRONG, and the way it was wrong is the point.
79
+ -- It required `update agent_board.board_memberships`, so it named a class its predicate did not
80
+ -- cover: FOUR separate decoys each left it fully green -- `insert ... on conflict ... do update
81
+ -- set` (the shape the invite-link path actually uses), `merge into`, the unqualified spelling,
82
+ -- and dynamic SQL inside a literal. Three of the four were found by the review board, not by
83
+ -- the author, on a draft whose own header already claimed the class was closed.
84
+ --
85
+ -- THE SECOND DOOR, checked rather than assumed. `_redeem_unbounded_invite_for_agent` reaches an
86
+ -- ACTIVE membership by that very ON CONFLICT DO UPDATE. Its SET list lifts `role` and
87
+ -- `updated_at` only, so it cannot move the state -- and the test proves that behaviourally (a
88
+ -- joined member redeems a `subscribed`-preset link and stays joined), not by citing this note.
89
+ --
90
+ -- UNTOUCHED TRANSITIONS, asserted positively in the test: joined <-> subscribed (a self-toggle
91
+ -- must still work), active -> left/removed, and re-entry to joined from any non-active state
92
+ -- (the ROLERESET #79 clamp still applies on that path). These are load-bearing, not decoration:
93
+ -- a guard broadened to block every offer regardless of current state trips
94
+ -- `board_memberships_state_left_at` on the re-invite path, and every mutating call in the test
95
+ -- is wrapped in lives_ok for that reason. Measured both ways on this file: as a BARE call the
96
+ -- transaction aborts and pgTAP prints ZERO `not ok` lines -- the failure is invisible, not
97
+ -- loud -- while wrapped it runs every assertion and fails two of them BY NAME.
98
+ --
99
+ -- Stated without a count on purpose. An earlier version of this sentence carried one, and the
100
+ -- review board caught it stale: the count had been re-denominated rather than re-measured when
101
+ -- the file grew. A hand-maintained number describing a machine-derivable fact is rule 1's
102
+ -- anti-pattern applied to prose, and it drifts the same way.
103
+
104
+ CREATE OR REPLACE FUNCTION agent_board._upsert_board_membership_state(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb, p_membership_id uuid DEFAULT NULL::uuid, p_event_kind text DEFAULT NULL::text)
105
+ RETURNS agent_board.board_memberships
106
+ LANGUAGE plpgsql
107
+ SECURITY DEFINER
108
+ SET search_path TO 'agent_board', 'public'
109
+ AS $function$
110
+ declare
111
+ v_membership agent_board.board_memberships%rowtype;
112
+ v_metadata jsonb;
113
+ -- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
114
+ v_currently_removed boolean;
115
+ v_prior_state text;
116
+ -- #317: the resolved next membership_state, computed ONCE and then both written and compared.
117
+ -- An earlier draft wrote the guard as a CASE in the SET clause and RESTATED the same predicate
118
+ -- to decide whether to emit. The two agreed, and nothing asserted they must: dropping
119
+ -- `subscribed` from the emit copy alone re-created the false-ledger defect verbatim, and adding
120
+ -- `removed` to it alone silently dropped a real state change from an append-only ledger. Both
121
+ -- left the entire suite green. Deriving once removes the possibility rather than testing for it.
122
+ v_next_state text;
123
+ v_offer_noop boolean := false;
124
+ begin
125
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
126
+ raise exception 'invalid board membership state' using errcode = '22023';
127
+ end if;
128
+
129
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
130
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
131
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
132
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
133
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
134
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
135
+ end if;
136
+
137
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
138
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
139
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
140
+ -- the column directly.
141
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
142
+ into v_metadata
143
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
144
+ where e.key not like 'ops\_%';
145
+
146
+ -- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
147
+ -- (exists removed AND no active), not a single total-ordered row. A higher-id
148
+ -- removed row can coexist with an active row; inferring "removed" from the
149
+ -- total-order winner alone would then try to activate the removed row and
150
+ -- collide with idx_agent_board_board_memberships_active_unique (23505).
151
+ -- Capture standing before the row lock so the predicate is independent of
152
+ -- which physical row the active-preferring SELECT below returns.
153
+ v_currently_removed :=
154
+ exists (
155
+ select 1 from agent_board.board_memberships bm
156
+ where bm.board_id = p_board_id
157
+ and bm.agent_id = p_agent_id
158
+ and bm.membership_state = 'removed'
159
+ ) and not exists (
160
+ select 1 from agent_board.board_memberships bm
161
+ where bm.board_id = p_board_id
162
+ and bm.agent_id = p_agent_id
163
+ and bm.membership_state in ('joined', 'subscribed')
164
+ );
165
+
166
+ -- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
167
+ -- exists, then total-order by created_at desc, id desc. A pure total-order
168
+ -- can select a higher-id REMOVED row while an ACTIVE row still exists.
169
+ -- When v_currently_removed, no active row exists and the total-order pick is
170
+ -- non-active (re-entry clamp applies below).
171
+ -- When the caller names a row it has already validated AND LOCKED, act on THAT row. The
172
+ -- active-preferring pick below is right when the sink chooses for itself, but wrong when a
173
+ -- caller validated a specific one: rejecting a pending request would lock and mutate the
174
+ -- subject's ACTIVE membership instead, downgrading a real member (#109).
175
+ if p_membership_id is not null then
176
+ select *
177
+ into v_membership
178
+ from agent_board.board_memberships bm
179
+ where bm.id = p_membership_id
180
+ and bm.board_id = p_board_id
181
+ and bm.agent_id = p_agent_id
182
+ for update;
183
+
184
+ if v_membership.id is null then
185
+ raise exception 'membership row % not found for this subject', p_membership_id
186
+ using errcode = 'P0002';
187
+ end if;
188
+ else
189
+ select *
190
+ into v_membership
191
+ from agent_board.board_memberships bm
192
+ where bm.board_id = p_board_id
193
+ and bm.agent_id = p_agent_id
194
+ order by
195
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
196
+ bm.created_at desc,
197
+ bm.id desc
198
+ limit 1
199
+ for update;
200
+ end if;
201
+
202
+ -- The PRIOR state, captured before the update below overwrites v_membership. The emitter
203
+ -- cannot see it, and without it a joined<->subscribed self-toggle is indistinguishable from
204
+ -- a fresh join.
205
+ v_prior_state := v_membership.membership_state;
206
+
207
+ if v_membership.id is null then
208
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
209
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
210
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
211
+ values (
212
+ p_board_id,
213
+ p_agent_id,
214
+ p_membership_state,
215
+ p_created_by_agent_id,
216
+ case when p_membership_state in ('left', 'removed') then now() else null end,
217
+ v_metadata
218
+ )
219
+ returning * into v_membership;
220
+ else
221
+ -- THE #317 RULE, in one place. `invited` and `requested` are OFFERS: they describe a
222
+ -- membership that does not yet exist. Applying one to a membership that DOES exist
223
+ -- (`joined`/`subscribed`) leaves the state alone -- the offer is already satisfied and the
224
+ -- stronger standing wins. Every other transition is untouched, including joined <-> subscribed,
225
+ -- active -> left/removed, and re-entry from any non-active state.
226
+ v_next_state := case
227
+ when p_membership_state in ('invited', 'requested')
228
+ and v_membership.membership_state in ('joined', 'subscribed')
229
+ then v_membership.membership_state
230
+ else p_membership_state
231
+ end;
232
+
233
+ -- Suppressed iff the state did not move AND the caller asked for something else. The second
234
+ -- conjunct matters: join_board and update_board_membership both permit joined -> joined, which
235
+ -- also does not move the state but IS the caller getting what they asked for, and still emits.
236
+ v_offer_noop := v_next_state is not distinct from v_membership.membership_state
237
+ and p_membership_state is distinct from v_next_state;
238
+
239
+ update agent_board.board_memberships bm
240
+ -- #317: AN INVITE MUST NOT WEAKEN AN ACTIVE MEMBERSHIP.
241
+ --
242
+ -- Reproduced on production and again in a scratch database: an agent JOINED since
243
+ -- 2026-08-05 was moved back to `invited` by a single invite_offer, because this UPDATE
244
+ -- set the requested state unconditionally. The row was never deleted -- it was
245
+ -- DOWNGRADED, and the agent silently lost board delivery while both sides saw a board
246
+ -- that merely looked quiet.
247
+ --
248
+ -- This is NOT #109 and the #109 guard does not cover it. #109 was the sink acting on the
249
+ -- WRONG ROW (an active row when a pending one had been validated), fixed by the
250
+ -- p_membership_id targeting above. This is the RIGHT row moved in the WRONG DIRECTION,
251
+ -- so it needs a direction rule, not a selection rule.
252
+ --
253
+ -- The rule: `invited` and `requested` are OFFERS -- they describe a membership that does
254
+ -- not yet exist. Applying an offer to one that DOES exist (`joined`/`subscribed`) leaves
255
+ -- the state alone: the offer is already satisfied and the stronger standing wins. Every
256
+ -- other transition is untouched, including joined <-> subscribed, active -> left/removed,
257
+ -- and re-entry from any non-active state.
258
+ --
259
+ -- A no-op rather than an exception, deliberately: invite is idempotent by design (the
260
+ -- MBCONSENT invite flow re-offers freely) and raising here would break callers that
261
+ -- succeed today.
262
+ --
263
+ -- "No-op" IS COLUMN-SCOPED, and saying so is the difference between a true claim and a
264
+ -- comfortable one. What a suppressed offer preserves is the AUTHORITY-BEARING state:
265
+ -- membership_state, left_at, and role. The row is still WRITTEN -- created_by_agent_id,
266
+ -- metadata and updated_at all follow the ordinary path below. That is pre-existing
267
+ -- behaviour this change does not alter: created_by_agent_id is audit provenance surfaced
268
+ -- as created_by_handle (20260507000000:85-87), not authority, and the metadata merge is
269
+ -- what every other caller gets. Making a suppressed offer skip the UPDATE entirely would
270
+ -- be defensible and is deliberately NOT done here -- it would change metadata semantics
271
+ -- for a path unrelated to the defect, in a fix meant to be narrow. Both the preserved
272
+ -- columns and the written ones are asserted, so the scope is a fact rather than a promise.
273
+ -- v_next_state, derived above. NOTE the deliberate asymmetry with the two CASEs below:
274
+ -- `role` and `left_at` still key off p_membership_state, the caller's REQUEST, and that is
275
+ -- correct rather than an oversight -- the #79 role clamp fires on ADMISSION to an active
276
+ -- state, and a suppressed offer admits nobody.
277
+ --
278
+ -- Stated precisely, because a first draft of this comment overclaimed and a mutation
279
+ -- refuted it: keying the clamp off v_next_state instead does NOT reset an ordinary owner,
280
+ -- because the clamp's second conjunct already requires the PRIOR state to be non-active
281
+ -- (or currently-removed standing to hold), and on a suppressed offer the prior state is
282
+ -- active by construction. The two spellings differ only in the `v_currently_removed`
283
+ -- corner, where the selected row is active while a removed duplicate exists. So the
284
+ -- asymmetry is worth keeping and worth explaining, but it is not the gaping hole the
285
+ -- draft claimed. The assertions below pin the ordinary case, which is what they cover.
286
+ set membership_state = v_next_state,
287
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
288
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
289
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
290
+ left_at = case
291
+ when p_membership_state in ('left', 'removed') then
292
+ case
293
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
294
+ -- repeat call cannot push the retention cutoff forward.
295
+ when v_membership.membership_state = p_membership_state
296
+ then coalesce(v_membership.left_at, now())
297
+ -- GENUINE transition (notably left -> removed, an owner barring
298
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
299
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
300
+ -- so this is the real kick moment; inheriting the earlier departure would
301
+ -- both lose the audit answer and make the marker row retention-eligible
302
+ -- prematurely.
303
+ else now()
304
+ end
305
+ else null
306
+ end,
307
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
308
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
309
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
310
+ -- Unconditional — no service_role exemption.
311
+ -- MBROWID: origin is non-active when the selected row is non-active OR when
312
+ -- currently-removed standing holds (R-LINK-7 set form). With active-preferring
313
+ -- selection those agree; the set form is load-bearing against equal-created_at
314
+ -- removed+left duplicates where a single-row read would flip with id order.
315
+ role = case
316
+ when p_membership_state in ('joined', 'subscribed')
317
+ and (
318
+ v_membership.membership_state not in ('joined', 'subscribed')
319
+ or v_currently_removed
320
+ )
321
+ then 'member'
322
+ else bm.role
323
+ end,
324
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
325
+ updated_at = now()
326
+ where bm.id = v_membership.id
327
+ returning * into v_membership;
328
+ end if;
329
+
330
+ -- Emission is delegated so the sink and the four raw writers cannot drift apart.
331
+ -- The frozen mapping distinguishes a self-TOGGLE between two active states from a fresh
332
+ -- self-JOIN. Only the sink knows the prior state, so it names the kind; the emitter derives
333
+ -- everything security-critical regardless.
334
+ -- A caller-supplied kind wins; otherwise the sink's own derivation stands. Only the
335
+ -- DESCRIPTIVE field is delegated -- provenance is always derived from the session.
336
+ --
337
+ -- #317 -- NOTHING IS EMITTED WHEN NOTHING HAPPENED, and both of the obvious alternatives are
338
+ -- WORSE. Before the guard above, the argument and the resulting row could not differ, so the
339
+ -- question did not arise. The guard makes them differ on exactly one path, and that path then
340
+ -- has three options, two of which are defects:
341
+ --
342
+ -- * emit `p_membership_state` (what the first draft of this migration did): appends
343
+ -- `result_state = 'invited'` beside a row that says `joined`. `board_membership_events` is
344
+ -- append-only, so the false row can never be corrected -- and it is the table the #317
345
+ -- investigation itself read to establish what happened. The fix would have falsified its
346
+ -- own diagnostic. Caught by three review seats.
347
+ --
348
+ -- * emit `v_membership.membership_state` (what the SECOND draft did, "fixing" the above):
349
+ -- MINTS CONSENT OUT OF AN OFFER, which is strictly worse than a cosmetically wrong row.
350
+ -- `_membership_consent_present` -- the FROZEN MBCONSENT interface (IF-0-MBCONSENT-1) that
351
+ -- `update_board_membership` consults before admitting anyone -- treats
352
+ -- `operator-designated` + `result_state in ('joined','subscribed')` as an actual PLACEMENT,
353
+ -- i.e. as consent. An operator re-inviting a member whose ledger is silent would therefore
354
+ -- turn a mere OFFER into an admission grant. Measured, not reasoned:
355
+ --
356
+ -- consent BEFORE operator re-invite = false
357
+ -- consent AFTER operator re-invite = true
358
+ --
359
+ -- That is the precise narrowing MBCONSENT shipped 20260806000100 to make -- "a bare operator
360
+ -- `invited` OFFER must be NEUTRAL" -- reopened from the other side. Delegating a kind cannot
361
+ -- avoid it either: the predicate keys on (provenance, result_state) ONLY, and provenance is
362
+ -- derived from the session and is not delegatable.
363
+ --
364
+ -- * emit nothing, which is what this does. `board_membership_events` records membership-state
365
+ -- events; a suppressed offer changed no state, so there is no event. A silent ledger reads
366
+ -- as `false` from the consent predicate, so this cannot mint anything, and it writes no row
367
+ -- that a later reader must distrust.
368
+ --
369
+ -- WHAT THIS GIVES UP, stated rather than left to be discovered: an invite that lands on an
370
+ -- already-active member is no longer visible in this ledger at all. That is a real loss of
371
+ -- audit for a real action. It is accepted because the only two ways to record it here are a
372
+ -- false statement about the row or a forged consent grant, and because the CALLER still sees
373
+ -- the outcome -- invite_board_member and update_board_membership both return the row, which
374
+ -- now reads `joined` rather than `invited`. If that action needs auditing, it needs a surface
375
+ -- that is not the consent ledger.
376
+ --
377
+ -- Emission is otherwise unchanged and still delegated, so the sink and the raw writers cannot
378
+ -- drift apart.
379
+ if not v_offer_noop then
380
+ perform agent_board._emit_membership_event(
381
+ p_board_id, p_agent_id, p_membership_state,
382
+ coalesce(p_event_kind, case
383
+ when v_prior_state in ('joined', 'subscribed')
384
+ and p_membership_state in ('joined', 'subscribed')
385
+ -- The states must actually DIFFER. join_board and update_board_membership both permit
386
+ -- joined->joined and subscribed->subscribed, and a write that changes nothing is not a
387
+ -- toggle of anything -- recording it as one permanently mislabels an append-only event.
388
+ and v_prior_state <> p_membership_state
389
+ and not agent_board._is_service_role()
390
+ and agent_board.current_agent_id() = p_agent_id
391
+ then 'consent_self_toggle'
392
+ else null
393
+ end));
394
+ end if;
395
+
396
+ return v_membership;
397
+ end;
398
+ $function$;
@@ -0,0 +1,139 @@
1
+ -- #310 — `signed_write_enforced` reports false for a system that IS enforcing.
2
+ --
3
+ -- THE DEFECT. `get_bundle_compatibility` reported `signed_write_enforced: false` on the canonical
4
+ -- project while enforcement was intact. Reproduced on a FRESHLY BUILT database from this tree's
5
+ -- bundle, so it is in the schema and not an artifact of one deployment.
6
+ --
7
+ -- The predicate encoded a model this project deliberately ABANDONED. It asked whether
8
+ -- `authenticated` can EXECUTE four raw write RPCs, and reported enforcement only when it cannot.
9
+ -- `20260718000800_agent_board_regrant_scoped_send.sql` re-granted EXECUTE on the eight send
10
+ -- overloads on purpose -- its own header calls MBSESSION "the enforcement-switches-on point (the
11
+ -- re-grant makes the authenticated live path real)". Enforcement moved from "authenticated cannot
12
+ -- call it" to "the call gates INSIDE the function". The flag never moved with it.
13
+ --
14
+ -- WHY THAT IS WORTH A MIGRATION RATHER THAN A SHRUG. A health check that reports `false` for a
15
+ -- system that is enforcing trains its readers to ignore it, which is worse than having no flag:
16
+ -- the next real regression reads exactly the same. It also contradicted specs/operations.md.
17
+ --
18
+ -- THE INSTRUMENT WAS ALSO WRONG, independently of the model. `information_schema.
19
+ -- role_routine_grants` shows DIRECT grants only, and only for roles the current user can see --
20
+ -- the fail-open shape #302 corrected elsewhere. `has_function_privilege` resolves privileges
21
+ -- inherited through role membership AND the PUBLIC default, which is the #118 class: a
22
+ -- SECURITY DEFINER function recreated without restating its revoke is EXECUTE-able by PUBLIC,
23
+ -- and a direct-grant read cannot see it. Every privilege question here now uses the resolver.
24
+ --
25
+ -- WHAT THE FLAG MEANS NOW, stated as exactly what it tests:
26
+ --
27
+ -- 1. the signed gateway EXISTS (the anchor -- without it the second clause is vacuously true,
28
+ -- because a schema with no signed_* functions trivially grants none of them);
29
+ -- 2. NO function named `signed\_%` is EXECUTE-able by `authenticated` or `anon`.
30
+ --
31
+ -- The `signed_%` set is read from pg_proc, NOT the four-name list the old predicate carried:
32
+ -- there are SEVEN signed gateways, and a hand list that said four is precisely the failure
33
+ -- CLAUDE.md rule 1 exists to stop. It iterates pg_proc ROWS rather than resolving names, because
34
+ -- `to_regproc` returns NULL on an OVERLOADED name and the send family is overloaded -- a
35
+ -- name-based check would silently skip exactly the functions most worth checking.
36
+ --
37
+ -- WHAT THIS FLAG DELIBERATELY DOES NOT TEST. "Cannot" would overstate it, so: what follows
38
+ -- refutes a shipped prosrc SCAN, which is one alternative, not the whole class. A body-identity
39
+ -- FINGERPRINT -- shipping expected (proname, proargtypes, hash(prosrc)) rows and comparing live
40
+ -- pg_proc against them -- is immune to the comment objection, since a comment changes the hash
41
+ -- and fails in the SAFE direction, and the repo already ships two checked-in catalog snapshots
42
+ -- regenerated under `schema:after-migration --fix`. It is rejected as EXPENSIVE AND NOISY, not
43
+ -- impossible: every migration touching a gated function would have to update the table in the
44
+ -- same migration, and a comment-only edit would produce a false `false` -- an instrument that
45
+ -- cries wolf, which is #310's own failure mode. The scan argument below stands on its own.
46
+ -- "Every unsigned writer derives its actor from the session and gates" is a property of function
47
+ -- BODIES. Testing it here would mean a shipped function scanning `prosrc` at runtime -- no
48
+ -- shipped function does that today, and a raw scan is satisfied by a COMMENT, which is the
49
+ -- #196/#199 class. The test-only strip helpers that make such a scan sound live in
50
+ -- `agent_board_test`, and shipping a second copy into `agent_board` is the two-copies-drift
51
+ -- failure #199 already names.
52
+ --
53
+ -- So that half is asserted in agent_board_signed_write_enforced.test.sql instead, over the CALL
54
+ -- CLOSURE rather than direct bodies -- which is load-bearing: only 3 of the 7 unsigned
55
+ -- counterparts name `_resolved_actor_agent_id` in their own body. The other four DELEGATE to
56
+ -- `post_message`, so a direct body scan would report them ungated and be wrong.
57
+ --
58
+ -- CONSEQUENCE, stated with the qualifier that makes it TRUE. A new ungated writer that is the
59
+ -- COUNTERPART OF A SIGNED GATEWAY turns the pgTAP class assertion red, not this flag. A new
60
+ -- ungated writer with NO signed twin turns NEITHER red: every class-scoping predicate in that
61
+ -- file bounds the class by `signed\_%` and derives the counterpart by stripping the prefix, so
62
+ -- such a writer is outside the class by construction. Verified rather than reasoned -- an
63
+ -- authenticated-executable SECURITY DEFINER writer deriving nothing from the session left the
64
+ -- flag true and all 19 assertions green.
65
+ --
66
+ -- An earlier draft of this sentence, and of the COMMENT below, said "a new UNGATED writer"
67
+ -- without the qualifier. The review board caught it. The unqualified form is this repo's most
68
+ -- common defect -- a claim broader than its evidence -- and it was in the one statement of
69
+ -- meaning that ships INSIDE the database and outlives this PR.
70
+ --
71
+ -- Closing it properly means an EFFECT-derived class ("every authenticated-executable function
72
+ -- that writes a message/task/authority row") rather than a NAME-derived one. That shape already
73
+ -- exists in this repo: agent_board_consent_gating.test.sql EC-4 derives its class from what a
74
+ -- function REACHES, explicitly refusing an enumerated list. It is filed as a follow-up rather
75
+ -- than done here, because it is a bigger class than #310's subject and deserves its own review.
76
+ --
77
+ -- CREATE OR REPLACE with the identical signature, so the existing ACL survives -- verified
78
+ -- before writing this: `authenticated` and `service_role` hold EXECUTE, `anon` does not, and
79
+ -- PUBLIC holds nothing. The grants are restated anyway and a COMMENT added, because a recreated
80
+ -- SECURITY DEFINER function that does not restate them is the gate-bypass this repo has already
81
+ -- shipped once. The executor set is pinned by assertion, not by this note.
82
+
83
+ CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
84
+ RETURNS jsonb
85
+ LANGUAGE sql
86
+ STABLE SECURITY DEFINER
87
+ SET search_path TO 'agent_board', 'pg_temp'
88
+ AS $function$
89
+ select jsonb_build_object(
90
+ 'schema_version', (select version from agent_board.schema_version limit 1),
91
+ 'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
92
+ 'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
93
+ 'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
94
+ 'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
95
+ -- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
96
+ -- unreachable. See this migration's header for what this does and does not establish.
97
+ 'signed_write_enforced', (
98
+ -- The anchor. A schema with no signed gateway grants none of them, which would make the
99
+ -- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
100
+ -- from meaning "nothing to enforce".
101
+ exists (
102
+ select 1
103
+ from pg_catalog.pg_proc p
104
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
105
+ where n.nspname = 'agent_board'
106
+ and p.proname = 'signed_send_direct_message'
107
+ )
108
+ and not exists (
109
+ -- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
110
+ -- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
111
+ select 1
112
+ from pg_catalog.pg_proc p
113
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
114
+ cross join (values ('authenticated'), ('anon')) as r(rolname)
115
+ where n.nspname = 'agent_board'
116
+ and p.proname like 'signed\_%'
117
+ and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
118
+ )
119
+ ),
120
+ -- Ready only once the materializer has recorded an install AND the schema
121
+ -- version handshake resolves.
122
+ 'ready', (
123
+ (select version from agent_board.schema_version limit 1) is not null
124
+ and exists (select 1 from agent_board.bundle_installation)
125
+ ),
126
+ 'checked_at', now()
127
+ );
128
+ $function$;
129
+
130
+ -- Restated rather than relied upon. CREATE OR REPLACE preserves the ACL, but this repo has
131
+ -- already shipped a gate-bypass by recreating a SECURITY DEFINER function without restating its
132
+ -- privileges, so the rule is to restate them in the same migration every time. `anon` is
133
+ -- deliberately NOT granted: it did not hold EXECUTE before this migration and must not gain it.
134
+ revoke all on function agent_board.get_bundle_compatibility() from public;
135
+ grant execute on function agent_board.get_bundle_compatibility() to authenticated;
136
+ grant execute on function agent_board.get_bundle_compatibility() to service_role;
137
+
138
+ comment on function agent_board.get_bundle_compatibility() is
139
+ 'Bundle/schema handshake for adopters. `signed_write_enforced` is TRUE iff the signed gateway exists AND no agent_board.signed_* function (all overloads, via has_function_privilege) is EXECUTE-able by authenticated or anon. GUIDANCE, not an assertion: read a FALSE together with installed_bundle_version from the same response -- below 1.37.0 it means the retired predicate, at or above it a real regression. That version is what the materializer RECORDED, which is evidence about intent rather than proof of which predicate body is live, so it is a decode hint and not a gate. It does NOT test that a writer derives its actor from the session and gates. That half is asserted in agent_board_signed_write_enforced.test.sql, and ONLY for the unsigned counterpart of a signed_* gateway -- a writer with no signed twin is outside both instruments. #310.';
@@ -0,0 +1,107 @@
1
+ -- SIGNEDWRITEMODEL (message-board#326) — a self-describing sibling for
2
+ -- `signed_write_enforced` in the bundle/schema handshake.
3
+ --
4
+ -- THE PROBLEM. After #324 the flag has the right VALUE and the wrong NAME. It is
5
+ -- called "signed write enforced" and it tests "the signed path is operator-only".
6
+ -- Those are different propositions, and a boolean cannot say which one it means.
7
+ -- The consequence is already written down in specs/operations.md: a reader must
8
+ -- combine the flag with `installed_bundle_version` to know which MEANING they are
9
+ -- looking at — below 1.37.0 it tests the model 20260718000800 abandoned, at or
10
+ -- above it tests the current one. That works and it asks every reader to carry a
11
+ -- version boundary in their head.
12
+ --
13
+ -- WHY NOT RENAME OR RETIRE, which is the strongest argument in this area.
14
+ -- packages/agent-board-client/src/compatibility.ts recognises a health response
15
+ -- with a runtime TYPE GUARD:
16
+ -- "schema_version" in value && "signed_write_enforced" in value && "ready" in value
17
+ -- Drop the key and every deployed client stops recognising a HEALTHY board's
18
+ -- response as a health response at all. The other spelling of retirement — keep
19
+ -- the key, pin it false/null forever — is a permanently-lying instrument, which is
20
+ -- precisely the harm #310 names. Retirement loses both ways, which is why #324
21
+ -- re-pointed rather than replaced, and why this migration ADDS.
22
+ --
23
+ -- WHY ADDING IS SAFE, and how far that claim goes. The guard requires those three
24
+ -- keys to be PRESENT; it does not enumerate the payload. Checked rather than
25
+ -- assumed: no consumer in this repository asserts an exhaustive key set over
26
+ -- `get_bundle_compatibility()` — the pgTAP consumers read individual keys with
27
+ -- `->>`, and the client reads named fields. A consumer OUTSIDE this repository
28
+ -- that pins the whole object would still be broken by any added key, which is a
29
+ -- property of adding keys and not of this one.
30
+ --
31
+ -- NOT PROPOSED: gating on it. A sound gate is constructible
32
+ -- (`installed_bundle_version >= 1.37.0 AND flag true`) but semver strings need
33
+ -- real comparison, and the recorded version is what the materializer WROTE, not
34
+ -- necessarily what is APPLIED. That makes a good argument and a bad gate, so this
35
+ -- ships as a decode hint like the boolean beside it.
36
+ --
37
+ -- FUTURE-PROOFING is the actual point. The next posture change adds a new VALUE
38
+ -- here rather than silently re-pointing a boolean for a second time — which is
39
+ -- exactly what #310 was.
40
+ --
41
+ -- CREATE OR REPLACE with the identical signature, so the existing ACL survives.
42
+ -- The grants are restated anyway and the COMMENT rewritten, because a recreated
43
+ -- SECURITY DEFINER function that does not restate them is the gate-bypass this
44
+ -- repo has already shipped once (#118). `anon` is deliberately NOT granted.
45
+
46
+ CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
47
+ RETURNS jsonb
48
+ LANGUAGE sql
49
+ STABLE SECURITY DEFINER
50
+ SET search_path TO 'agent_board', 'pg_temp'
51
+ AS $function$
52
+ select jsonb_build_object(
53
+ 'schema_version', (select version from agent_board.schema_version limit 1),
54
+ 'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
55
+ 'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
56
+ 'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
57
+ 'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
58
+ -- #326: which PROPOSITION the boolean below is answering, said out loud, so a
59
+ -- reader does not have to consult a version boundary to decode it. A constant
60
+ -- rather than a derivation: it names the model this bundle's predicate encodes,
61
+ -- which is a fact about the migration you are reading, not about the database.
62
+ 'signed_write_model', 'operator-only-gateway',
63
+ -- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
64
+ -- unreachable. See 20260918000100's header for what this does and does not establish.
65
+ 'signed_write_enforced', (
66
+ -- The anchor. A schema with no signed gateway grants none of them, which would make the
67
+ -- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
68
+ -- from meaning "nothing to enforce".
69
+ exists (
70
+ select 1
71
+ from pg_catalog.pg_proc p
72
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
73
+ where n.nspname = 'agent_board'
74
+ and p.proname = 'signed_send_direct_message'
75
+ )
76
+ and not exists (
77
+ -- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
78
+ -- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
79
+ select 1
80
+ from pg_catalog.pg_proc p
81
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
82
+ cross join (values ('authenticated'), ('anon')) as r(rolname)
83
+ where n.nspname = 'agent_board'
84
+ and p.proname like 'signed\_%'
85
+ and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
86
+ )
87
+ ),
88
+ -- Ready only once the materializer has recorded an install AND the schema
89
+ -- version handshake resolves.
90
+ 'ready', (
91
+ (select version from agent_board.schema_version limit 1) is not null
92
+ and exists (select 1 from agent_board.bundle_installation)
93
+ ),
94
+ 'checked_at', now()
95
+ );
96
+ $function$;
97
+
98
+ -- Restated rather than relied upon. CREATE OR REPLACE preserves the ACL, but this repo has
99
+ -- already shipped a gate-bypass by recreating a SECURITY DEFINER function without restating its
100
+ -- privileges, so the rule is to restate them in the same migration every time. `anon` is
101
+ -- deliberately NOT granted: it did not hold EXECUTE before this migration and must not gain it.
102
+ revoke all on function agent_board.get_bundle_compatibility() from public;
103
+ grant execute on function agent_board.get_bundle_compatibility() to authenticated;
104
+ grant execute on function agent_board.get_bundle_compatibility() to service_role;
105
+
106
+ comment on function agent_board.get_bundle_compatibility() is
107
+ 'Bundle/schema handshake for adopters. `signed_write_model` NAMES the proposition the boolean beside it answers -- `operator-only-gateway` means "the signed gateway exists and no agent_board.signed_* function (all overloads, via has_function_privilege) is EXECUTE-able by authenticated or anon". Read the pair: the model says WHAT is being tested, `signed_write_enforced` says whether it HOLDS. PRESENCE of this key implies the current meaning and needs no semver comparison. ABSENCE does NOT imply the retired one: the boolean changed meaning at bundle 1.37.0 and this key starts at 1.38.0, so on exactly 1.37.x the key is absent while a FALSE is a REAL regression. Absent means consult installed_bundle_version, not "nothing to see". Neither field is a gate. Neither tests that a writer derives its actor from the session and gates -- that half is asserted in agent_board_signed_write_enforced.test.sql, and ONLY for the unsigned counterpart of a signed_* gateway, so a writer with no signed twin is outside both instruments. #310, #326.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.28.0",
3
+ "version": "1.38.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",