@consiliency/agent-board-schema 1.27.0 → 1.35.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 (35) hide show
  1. package/fixtures/already-applied.json +32 -0
  2. package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
  3. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  4. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  5. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  6. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  7. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  8. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  9. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  10. package/package.json +1 -1
  11. package/schema-manifest.json +75 -3
  12. package/src/tooling.test.mjs +47 -2
  13. package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
  14. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  15. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  16. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  17. package/tests/agent_board_fedsession.test.sql +17 -2
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  19. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  20. package/tests/agent_board_ledger_authority.test.sql +1081 -0
  21. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  22. package/tests/agent_board_signer_lifecycle.test.sql +29 -6
  23. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  24. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  25. package/tests/catalog/p_metadata_parameters.json +20 -0
  26. package/tests/fixtures/test-prereqs.sql +420 -11
  27. package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
  28. package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
  29. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  30. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  31. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  32. package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
  33. package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
  34. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  35. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.27.0",
3
+ "version": "1.35.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.27.0",
5
+ "packageVersion": "1.35.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": 126,
50
- "aggregateSha256": "7fbe4374b98783d1b88a075fd5b6c11723b40fccd6a4b4a7c7a80d3d1666ea2f",
49
+ "migrationCount": 134,
50
+ "aggregateSha256": "f63027fa44451c55a39fcc8f463df6ae6f4b92765578be053d8bf3b9a2bee07e",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1182,6 +1182,78 @@
1182
1182
  "sourceRef": "message-board",
1183
1183
  "bytes": 29336,
1184
1184
  "sha256": "bfb1ae60f69dae0a0aaf1007dbf14e171f324cdced1caf9f4d490f41a60c14e5"
1185
+ },
1186
+ {
1187
+ "order": 126,
1188
+ "id": "20260909000100_agent_board_ledger_revoked_authority",
1189
+ "filename": "20260909000100_agent_board_ledger_revoked_authority.sql",
1190
+ "origin": "message-board-forward",
1191
+ "sourceRef": "message-board",
1192
+ "bytes": 67493,
1193
+ "sha256": "7522e32c61f8845e511255d94d273d98eb7b72b2f906976c3a2494629927462d"
1194
+ },
1195
+ {
1196
+ "order": 127,
1197
+ "id": "20260910000100_agent_board_ceiling_lock_comment_truth",
1198
+ "filename": "20260910000100_agent_board_ceiling_lock_comment_truth.sql",
1199
+ "origin": "message-board-forward",
1200
+ "sourceRef": "message-board",
1201
+ "bytes": 7087,
1202
+ "sha256": "b8ddc658be347d9e7207111968d0b8de7a88759cb3cf181f61c4ef306086f384"
1203
+ },
1204
+ {
1205
+ "order": 128,
1206
+ "id": "20260911000100_agent_board_ledger_revoked_board_creation",
1207
+ "filename": "20260911000100_agent_board_ledger_revoked_board_creation.sql",
1208
+ "origin": "message-board-forward",
1209
+ "sourceRef": "message-board",
1210
+ "bytes": 22749,
1211
+ "sha256": "4291af2bd6c93b7d9f8ec596f41dc62edf941ea10a51529df04e223d1cbc50c5"
1212
+ },
1213
+ {
1214
+ "order": 129,
1215
+ "id": "20260912000100_agent_board_receipt_subject_gate",
1216
+ "filename": "20260912000100_agent_board_receipt_subject_gate.sql",
1217
+ "origin": "message-board-forward",
1218
+ "sourceRef": "message-board",
1219
+ "bytes": 19033,
1220
+ "sha256": "1083f7720ccab9c9b422415f2a8eb743de5d84b0ab8d8689059bd355514e9aa9"
1221
+ },
1222
+ {
1223
+ "order": 130,
1224
+ "id": "20260913000100_agent_board_task_thread_lock_order",
1225
+ "filename": "20260913000100_agent_board_task_thread_lock_order.sql",
1226
+ "origin": "message-board-forward",
1227
+ "sourceRef": "message-board",
1228
+ "bytes": 17738,
1229
+ "sha256": "259a05c4a6603518ab449e799fcb235b0e16a6650954cb2db19b436c0a7a1e83"
1230
+ },
1231
+ {
1232
+ "order": 131,
1233
+ "id": "20260914000100_agent_board_claim_subject_gate",
1234
+ "filename": "20260914000100_agent_board_claim_subject_gate.sql",
1235
+ "origin": "message-board-forward",
1236
+ "sourceRef": "message-board",
1237
+ "bytes": 34342,
1238
+ "sha256": "88207b2e3f28ec8022ad4ca921721ebba877668042c66b5ac74c85e4c8cefc21"
1239
+ },
1240
+ {
1241
+ "order": 132,
1242
+ "id": "20260915000100_agent_board_fedsession_stored_identity_gate",
1243
+ "filename": "20260915000100_agent_board_fedsession_stored_identity_gate.sql",
1244
+ "origin": "message-board-forward",
1245
+ "sourceRef": "message-board",
1246
+ "bytes": 18615,
1247
+ "sha256": "5a8f4abb822e30c2a695a310bb1742f2565bb2afd0b6a06cad43fcbdd106ad5e"
1248
+ },
1249
+ {
1250
+ "order": 133,
1251
+ "id": "20260916000100_agent_board_federated_identity_injective_slug",
1252
+ "filename": "20260916000100_agent_board_federated_identity_injective_slug.sql",
1253
+ "origin": "message-board-forward",
1254
+ "sourceRef": "message-board",
1255
+ "bytes": 84162,
1256
+ "sha256": "d9f05e18094954f2e4ee32bf7b129f0a9ffc39bc800a5efa9cf7938e1555c8dc"
1185
1257
  }
1186
1258
  ],
1187
1259
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 126)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 134)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(126);
43
+ expect(manifest.migrationCount).toBe(134);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -141,6 +141,51 @@ describe('manifest integrity', () => {
141
141
  // MBCONSENT-B(iii) (#109/#110 + the gating race): validate and mutate the SAME
142
142
  // row. The sink gains an optional row identity and event kind.
143
143
  '20260810000100_agent_board_consent_locking.sql',
144
+ // RECEIPTSUBJECTGATE (#287): a read receipt is a MEMO of access, never a SOURCE of it --
145
+ // mark_read requires the NAMED agent to pass the thread-access predicate, closing the
146
+ // route by which service_role could mint an inherit-thread grant for a third party. The
147
+ // predicate is parameterized (_agent_can_access_thread) and the current-agent form
148
+ // recomposed from it, so the disjunct set has ONE definition. The receipt grandfather is
149
+ // deliberately preserved and documented, not removed.
150
+ '20260912000100_agent_board_receipt_subject_gate.sql',
151
+ // CLAIMSUBJECTGATE (#291): a claim is a LOCK, never a KEY -- acquire_claim requires the
152
+ // NAMED agent to pass the thread-access predicate before a thread-bound claim is written,
153
+ // closing the sibling of the #287 mint on agent_board.claims (and, through the thread
154
+ // disjunct of _current_agent_can_access_task, on task access). The permanence of a
155
+ // released or expired claim is RATIFIED here rather than fixed, in comment on function
156
+ // and in specs/security.md, so a future reader cannot mistake it for an oversight.
157
+ '20260914000100_agent_board_claim_subject_gate.sql',
158
+ // TASKTHREADLOCKORDER (#281 R1): one total order for (threads, tasks) -- threads BEFORE
159
+ // tasks -- in append_task_event (an explicit `threads ... for key share` hoisted above the
160
+ // existing `tasks ... for update`, with a recheck of the pre-read thread_id) and in
161
+ // _upsert_delivery_recipient (the OBSGATE thread stamp hoisted above the
162
+ // delivery_recipients insert, whose FK takes KEY SHARE on tasks). Definition-only.
163
+ '20260913000100_agent_board_task_thread_lock_order.sql',
164
+ // FEDSESSIONIDENTITYGATE (#246): a federated human is returned only to the identity that
165
+ // OWNS it. The owner uid is sha256(issuer || '|' || subject), a concatenation with no
166
+ // length prefix and no escaping, so two distinct principals can derive the same uid --
167
+ // and the return path checked only `active` and `kind = 'human'`, neither of which says
168
+ // the row belongs to the caller. Both return sites now require the agent's stored
169
+ // metadata.federation issuer AND subject to equal the caller's verified pair: the
170
+ // idempotent branch and the unique_violation race handler, which returned an existing
171
+ // row with no comparison either. A missing stamp is a refusal (coalesce(..., false)),
172
+ // not a pass. The ambiguous DERIVATION is deliberately unchanged -- that is a separate
173
+ // migration with an auth.users.email re-key -- because this gate defeats the collision
174
+ // with the encoding still in place.
175
+ '20260915000100_agent_board_fedsession_stored_identity_gate.sql',
176
+ // FEDIDENTITYSLUG (#246): the (issuer, subject) -> owner slug derivation becomes ONE
177
+ // injective serialization, and the ambiguous one is dropped rather than kept beside it.
178
+ // `issuer || '|' || subject` has no length prefix and no escaping, so the pair is not
179
+ // recoverable from the preimage and two principals can derive one slug -- and the slug
180
+ // is not a label, it is the auth.users key _ensure_agent_auth_user builds the email
181
+ // from. All five live derivation sites now call agent_board._federated_identity_slug,
182
+ // which length-prefixes each component. NOT definition-only: the migration also
183
+ // BACKFILLS every existing federated owner across all four surfaces that carry the
184
+ // slug -- auth.users.email, raw_user_meta_data.slug, auth.identities, and the human
185
+ // agent's handle -- because re-deriving without re-keying the email orphans the owner
186
+ // (the #244 resumption lookup misses and the non-null owner conjunct fails). The owner
187
+ // uuid is unchanged, so no FK and no live session moves.
188
+ '20260916000100_agent_board_federated_identity_injective_slug.sql',
144
189
  ]) {
145
190
  expect(names.has(f)).toBe(true);
146
191
  }
@@ -110,12 +110,21 @@
110
110
  -- demonstrated silenced that way (#198). They are presence claims, so a
111
111
  -- comment can only make a MISSING construct look present -- the same
112
112
  -- fail-open direction, on weaker claims;
113
- -- * the two catalog CLASS assertions at the end. The serialization one decides
114
- -- serialized/UNSERIALIZED with `strpos(p.prosrc, '_lock_board_authority_ceiling')`
115
- -- over RAW prosrc, so a reader that DROPS the lock while keeping a comment
116
- -- naming it still prints "serialized" -- the dangerous direction. The
117
- -- surface-set predicates that pick the second class's member functions are raw
118
- -- for the same reason.
113
+ -- * the ONE remaining deliberate raw read, added by message-board#277: the
114
+ -- KNOWN-STALE record for set_board_authority_ceiling's inline every-reader
115
+ -- claim. Its evidence IS a comment, so a stripped body cannot see it by
116
+ -- construction. It is a PRESENCE claim, so the fail-open direction is "a
117
+ -- comment makes a missing thing look present" -- which is exactly what it is
118
+ -- asserting, deliberately.
119
+ --
120
+ -- CORRECTED BY message-board#277, because this paragraph was itself an example of
121
+ -- the defect #277 fixes -- prose claiming what the code no longer does. It used to
122
+ -- say the catalog CLASS assertions at the end decide serialized/UNSERIALIZED with
123
+ -- `strpos(p.prosrc, …)` over RAW prosrc. They do not, and had not since #196/#199:
124
+ -- the serialization state and the surface-set predicates all read
125
+ -- agent_board_test.stripped_body(), and one surface predicate reads
126
+ -- stripped_body(oid, true) because its marker is a string literal. Verified against
127
+ -- the statements themselves, not against this list.
119
128
  -- All of it is inside exact-set or presence assertions that #196 Bucket A
120
129
  -- deliberately excluded; it belongs to the follow-up that closes Buckets B and C,
121
130
  -- together with the nesting hole in the strip described above.
@@ -123,7 +132,7 @@ create extension if not exists pgtap with schema extensions;
123
132
  set search_path = extensions, public, agent_board;
124
133
 
125
134
  begin;
126
- select plan(54);
135
+ select plan(58);
127
136
 
128
137
  -- ---------------------------------------------------------------------------
129
138
  -- The stripped-body reader every positional assertion below uses is
@@ -491,42 +500,153 @@ select ok(
491
500
  'the ceiling setter holds the same lock before its owner gate and upsert [BACKSTOP: tests/isolation/specs/ceiling_setter_lock_before_upsert.spec]'
492
501
  );
493
502
 
503
+ -- THE CEILING CLASSES, DERIVED ONCE (message-board#277). Both sides of this lock
504
+ -- -- who READS the ceiling for authorization, and who WRITES it -- are needed by
505
+ -- four assertions below, one of which compares them to the lock helper's own
506
+ -- COMMENT. Written out four times they would be four things that can drift; the
507
+ -- drift would show up as one of them failing, but only after someone read four
508
+ -- copies to work out which was right. So the predicates live here, once.
509
+ --
510
+ -- CALL SHAPE for the serialization state, not strpos on a bare name: that is the
511
+ -- dangerous direction -- a reader that DROPS the lock but keeps a variable named
512
+ -- after it would still print "serialized".
513
+ -- SCHEMA-QUALIFIED, because a bare-name anchor proves call SYNTAX, not callee
514
+ -- IDENTITY: `\m_lock_board_authority_ceiling\s*\(` also matches
515
+ -- `zz_other._lock_board_authority_ceiling(...)` (measured true). Every live call
516
+ -- site in agent_board is `agent_board.`-qualified, so requiring the qualification
517
+ -- costs nothing today and is fail-CLOSED if someone later writes an unqualified
518
+ -- call (it reads as NOT calling). Overload count is pinned separately, so schema +
519
+ -- name is the identity.
520
+ --
521
+ -- The WRITE predicate names the DML verb and the relation together rather than
522
+ -- "mentions board_authority_ceilings", so _grant_within_ceiling -- which reads the
523
+ -- table and is the reason the read class exists at all -- is not miscounted as a
524
+ -- writer. It sees only STATIC SQL in the function's own body: a write built into an
525
+ -- `execute` string is a literal and is stripped away, and a write performed by a
526
+ -- TRIGGER or by a callee belongs to that object, not to this one. Same blindness
527
+ -- the positional claims above carry and for the same reason; stated here so the
528
+ -- class is not read as "every write to this table", which it is not.
529
+ create temp table _ceiling_class as
530
+ select 'agent_board.' || regexp_replace(p.oid::regprocedure::text, '^([^(]*\.)?', '') as ident,
531
+ p.proname::text as name, -- text, not `name`: array_agg(name) has no is(name[], text[]) overload
532
+ (strpos(lower(agent_board_test.stripped_body(p.oid)), '_grant_within_ceiling') > 0) as is_reader,
533
+ (agent_board_test.stripped_body(p.oid) ~*
534
+ '(insert\s+into|update|delete\s+from|merge\s+into)\s+(only\s+)?(agent_board\.)?board_authority_ceilings\M') as is_writer,
535
+ (agent_board_test.stripped_body(p.oid) ~* '\magent_board\._lock_board_authority_ceiling\s*\(') as serialized
536
+ from pg_proc p
537
+ join pg_namespace n on n.oid = p.pronamespace
538
+ where n.nspname = 'agent_board';
539
+
494
540
  -- CLASS (catalog-derived, EC-4 form). Every agent_board function that reads the
495
541
  -- ceiling for authorization, enumerated from pg_proc — never from a list written
496
542
  -- here — each carrying its serialization state POSITIVELY. A new reader changes
497
543
  -- the array and NAMES ITSELF in the diff; a reader that is serialized without
498
- -- being declared here also fails. The three UNSERIALIZED entries are the
499
- -- knowingly-unfixed remainder of the class recorded in 20260901000110's header:
500
- -- they carry the same absence race and are out of #186's scope.
544
+ -- being declared here also fails. The two UNSERIALIZED entries are what remains of
545
+ -- the knowingly-unfixed set recorded in 20260901000110's header (it recorded three;
546
+ -- #276 retired one): they carry the same absence race and are out of #186's scope.
501
547
  select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below are string literals in the expected array, never invoked
502
- (select array_agg(entry order by entry) from (
503
- select 'agent_board.' || regexp_replace(p.oid::regprocedure::text, '^([^(]*\.)?', '') || ' => ' ||
504
- -- CALL SHAPE, not strpos on a bare name: this is the dangerous
505
- -- direction -- a reader that DROPS the lock but keeps a variable named
506
- -- after it would still print "serialized".
507
- -- SCHEMA-QUALIFIED, because a bare-name anchor proves call SYNTAX, not callee
508
- -- IDENTITY: `\m_lock_board_authority_ceiling\s*\(` also matches
509
- -- `zz_other._lock_board_authority_ceiling(...)` (measured true). Every live call
510
- -- site in agent_board is `agent_board.`-qualified, so requiring the
511
- -- qualification costs nothing today and is fail-CLOSED if someone later writes
512
- -- an unqualified call (it reads as NOT calling). Overload count is pinned
513
- -- separately, so schema + name is the identity.
514
- case when agent_board_test.stripped_body(p.oid) ~* '\magent_board\._lock_board_authority_ceiling\s*\('
515
- then 'serialized' else 'UNSERIALIZED' end as entry
516
- from pg_proc p
517
- join pg_namespace n on n.oid = p.pronamespace
518
- where n.nspname = 'agent_board'
519
- and strpos(lower(agent_board_test.stripped_body(p.oid)), '_grant_within_ceiling') > 0
520
- ) readers),
548
+ (select array_agg(ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end
549
+ order by ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end)
550
+ from _ceiling_class where is_reader),
521
551
  array[
522
552
  'agent_board._redeem_unbounded_invite_for_agent(text,uuid) => UNSERIALIZED',
523
553
  'agent_board.create_invite_link(uuid,text,text,integer,timestamp with time zone,uuid,jsonb) => UNSERIALIZED',
524
- 'agent_board.grant_board_role(uuid,uuid,text,uuid) => UNSERIALIZED',
554
+ -- Retired from UNSERIALIZED by #276 (20260909000100). Adding the fleet-ledger gate made
555
+ -- grant_board_role row-lock both agents and board_memberships, which section 9 of
556
+ -- agent_board_signer_lifecycle.test.sql requires to be advisory-serialised; taking that
557
+ -- lock also closes this reader's exposure to the ceiling row's ABSENCE, one of the three
558
+ -- 20260901000110 left explicitly undetermined.
559
+ 'agent_board.grant_board_role(uuid,uuid,text,uuid) => serialized',
525
560
  'agent_board.onboard_federated_fleet_agent(text,text,text,jsonb,text,text,text,text,boolean,text,text,text[],text[],text,uuid,uuid,text,text,text,text,text,text,text) => serialized'
526
561
  ]::text[],
527
562
  'every ceiling-authorization reader in pg_proc is declared with its serialization state'
528
563
  );
529
564
 
565
+ -- CLASS (catalog-derived, message-board#277). The WRITE side of the same lock,
566
+ -- declared the same way. The helper's COMMENT makes a claim about writers as well
567
+ -- as readers, and until now only ONE writer was checked -- by name, in the
568
+ -- lock-before-upsert assertion above. A second writer could appear with no lock at
569
+ -- all and nothing here would notice; the comment would still say every writer takes
570
+ -- it. Now it names itself.
571
+ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC name below is a string literal in the expected array, never invoked
572
+ (select array_agg(ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end
573
+ order by ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end)
574
+ from _ceiling_class where is_writer),
575
+ array[
576
+ 'agent_board.set_board_authority_ceiling(uuid,text,uuid) => serialized'
577
+ ]::text[],
578
+ 'every function that WRITES board_authority_ceilings is declared with its serialization state'
579
+ );
580
+
581
+ -- The COMMENT on the lock helper is the most discoverable statement of intent about
582
+ -- it -- it is attached to the lock itself -- and until #277 it claimed a property
583
+ -- that the reader class above ACTIVELY REFUTED: "every RPC that writes the ceiling
584
+ -- and every RPC that reads it for authorization takes this FIRST". Two of the four
585
+ -- readers do not take it at all. Correcting the sentence would have fixed today and
586
+ -- left the same rot mechanism in place: prose three files away from the assertion
587
+ -- that knows the answer.
588
+ --
589
+ -- So the COMMENT now carries the two lists in a fixed machine-readable form and this
590
+ -- assertion compares them, name for name and state for state, to the SAME
591
+ -- catalog-derived classes the two assertions above declare. A reader or writer added,
592
+ -- removed, or moved between serialized and UNSERIALIZED fails here as well, and the
593
+ -- failure prints the disagreeing lists side by side.
594
+ --
595
+ -- FAIL-CLOSED ON A MANGLED BLOCK: an absent or malformed block makes substring()
596
+ -- return NULL, which is coalesced to a sentinel that no catalog list can equal --
597
+ -- never to the empty list, which is a real and different answer ('(none)').
598
+ -- Deleting the block is therefore a failure, not a silent pass. Mutation-verified in
599
+ -- all three directions (state flipped, name dropped, block deleted).
600
+ select is(
601
+ (select array[
602
+ coalesce(string_agg(name, ', ' order by name) filter (where is_writer and serialized), '(none)'),
603
+ coalesce(string_agg(name, ', ' order by name) filter (where is_writer and not serialized), '(none)'),
604
+ coalesce(string_agg(name, ', ' order by name) filter (where is_reader and serialized), '(none)'),
605
+ coalesce(string_agg(name, ', ' order by name) filter (where is_reader and not serialized), '(none)')
606
+ ] from _ceiling_class),
607
+ (select array[
608
+ coalesce(substring(c from 'WRITERS\[serialized: ([^]]*)\]'), '(NO WRITERS BLOCK IN THE COMMENT)'),
609
+ coalesce(substring(c from 'WRITERS\[serialized: [^]]*\]\[UNSERIALIZED: ([^]]*)\]'), '(NO WRITERS UNSERIALIZED BLOCK IN THE COMMENT)'),
610
+ coalesce(substring(c from 'READERS\[serialized: ([^]]*)\]'), '(NO READERS BLOCK IN THE COMMENT)'),
611
+ coalesce(substring(c from 'READERS\[serialized: [^]]*\]\[UNSERIALIZED: ([^]]*)\]'), '(NO READERS UNSERIALIZED BLOCK IN THE COMMENT)')
612
+ ] from (select obj_description('agent_board._lock_board_authority_ceiling(uuid)'::regprocedure, 'pg_proc') as c) d),
613
+ 'the ceiling lock COMMENT names exactly the catalog-derived writers and readers, with the same serialization state'
614
+ );
615
+
616
+ -- The COMMENT lists BARE NAMES (onboard_federated_fleet_agent alone has 23
617
+ -- parameters; a signature list would be unreadable and would rot on any signature
618
+ -- change). A bare name is an identity only while no two members of these classes
619
+ -- share one -- otherwise the comparison above would accept the wrong overload's
620
+ -- state. Catalog-derived, so a colliding overload fails here and prints its name.
621
+ select is(
622
+ (select array_agg(name order by name) from (
623
+ select name from _ceiling_class where is_reader or is_writer group by name having count(*) > 1
624
+ ) dupes),
625
+ null::text[],
626
+ 'no two ceiling readers/writers share a bare name, so the COMMENT may identify them by name'
627
+ );
628
+
629
+ -- KNOWN STALE, RECORDED POSITIVELY (message-board#277). The same overclaim the
630
+ -- helper's COMMENT carried is ALSO an inline comment inside
631
+ -- set_board_authority_ceiling's live body: "Same board-keyed advisory lock every
632
+ -- ceiling READER for authorization takes." It is false in the same way and for the
633
+ -- same two readers. It is NOT corrected in #277 because correcting prose inside a
634
+ -- body means recreating a SECURITY DEFINER function -- a much wider blast radius
635
+ -- than a `comment on function`, and a decision to take deliberately rather than to
636
+ -- smuggle into a comment fix.
637
+ --
638
+ -- Asserted POSITIVELY rather than left as a note in a PR body (CLAUDE.md rule 1: an
639
+ -- exemption states its claim, never relies on absence). WHEN YOU FIX IT: this
640
+ -- assertion fails and names itself -- delete it, and re-derive the COMMENT's blocks
641
+ -- from the catalog while you are there. Raw prosrc on purpose: the evidence IS a
642
+ -- comment, so the stripped body cannot see it (presence claim, not positional).
643
+ select ok(
644
+ (select p.prosrc from pg_proc p join pg_namespace n on n.oid = p.pronamespace
645
+ where n.nspname = 'agent_board' and p.proname = 'set_board_authority_ceiling')
646
+ like '%Same board-keyed advisory lock every ceiling READER for authorization takes%',
647
+ 'KNOWN STALE (#277): set_board_authority_ceiling still carries the refuted every-reader claim inline -- pinned here so fixing it cannot go unnoticed'
648
+ );
649
+
530
650
  -- CLASS (catalog-derived). Every function that touches this migration set's own
531
651
  -- surface — the bounded-invite table, the human revocation registry, the fleet
532
652
  -- revocation ledger, or the ceiling lock — with its exact non-owner EXECUTE
@@ -569,6 +689,12 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
569
689
  or strpos(agent_board_test.stripped_body(p.oid, true), 'board_authority_ceilings:') > 0)
570
690
  ) definers),
571
691
  array[
692
+ -- Joined this class in #276: it reads the fleet revocation ledger. This is the UNLOCKED
693
+ -- predicate; the share-locking _assert_actor_not_fleet_revoked wrapper left this surface
694
+ -- when the two were split, because it now reaches the ledger through this function rather
695
+ -- than naming the table itself. Internal — it reads rows for an arbitrary agent id with no
696
+ -- authorization of its own, so service_role only, like every other helper here.
697
+ 'agent_board._actor_fleet_revoked(uuid) [definer] => service_role',
572
698
  'agent_board._lock_board_authority_ceiling(uuid) [invoker] => service_role',
573
699
  'agent_board._redeem_fleet_invite_for_agent(text,uuid) [definer] => service_role',
574
700
  'agent_board._redeem_invite_for_agent(text,uuid) [definer] => service_role',
@@ -577,6 +703,11 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
577
703
  'agent_board.enroll_federated_human(text,text,text,jsonb,text) [definer] => service_role',
578
704
  'agent_board.enroll_federated_identity(text,text,text,jsonb,text,text,text,text,boolean,text,text,text[],text[],text) [definer] => service_role',
579
705
  'agent_board.evaluate_fleet_sender_policy(uuid,uuid,uuid,uuid,text,uuid,boolean) [definer] => authenticated,service_role',
706
+ -- Joined this class in #276: the live grant chokepoint now takes _lock_board_authority_ceiling
707
+ -- (forced by the section-9 agents/board_memberships class in agent_board_signer_lifecycle.test.sql
708
+ -- once the ledger gate made it lock both). Still authenticated,service_role — #276 removes
709
+ -- authority from a revoked principal, it does not change who may call the RPC.
710
+ 'agent_board.grant_board_role(uuid,uuid,text,uuid) [definer] => authenticated,service_role',
580
711
  'agent_board.onboard_federated_fleet_agent(text,text,text,jsonb,text,text,text,text,boolean,text,text,text[],text[],text,uuid,uuid,text,text,text,text,text,text,text) [definer] => service_role',
581
712
  'agent_board.reactivate_fleet_machine(uuid,uuid,text) [definer] => service_role',
582
713
  'agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb) [definer] => authenticated,service_role',