@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
@@ -508,6 +508,46 @@
508
508
  {
509
509
  "id": "20260909000100_agent_board_ledger_revoked_authority",
510
510
  "sha256": "7522e32c61f8845e511255d94d273d98eb7b72b2f906976c3a2494629927462d"
511
+ },
512
+ {
513
+ "id": "20260910000100_agent_board_ceiling_lock_comment_truth",
514
+ "sha256": "b8ddc658be347d9e7207111968d0b8de7a88759cb3cf181f61c4ef306086f384"
515
+ },
516
+ {
517
+ "id": "20260911000100_agent_board_ledger_revoked_board_creation",
518
+ "sha256": "4291af2bd6c93b7d9f8ec596f41dc62edf941ea10a51529df04e223d1cbc50c5"
519
+ },
520
+ {
521
+ "id": "20260912000100_agent_board_receipt_subject_gate",
522
+ "sha256": "1083f7720ccab9c9b422415f2a8eb743de5d84b0ab8d8689059bd355514e9aa9"
523
+ },
524
+ {
525
+ "id": "20260913000100_agent_board_task_thread_lock_order",
526
+ "sha256": "259a05c4a6603518ab449e799fcb235b0e16a6650954cb2db19b436c0a7a1e83"
527
+ },
528
+ {
529
+ "id": "20260914000100_agent_board_claim_subject_gate",
530
+ "sha256": "88207b2e3f28ec8022ad4ca921721ebba877668042c66b5ac74c85e4c8cefc21"
531
+ },
532
+ {
533
+ "id": "20260915000100_agent_board_fedsession_stored_identity_gate",
534
+ "sha256": "5a8f4abb822e30c2a695a310bb1742f2565bb2afd0b6a06cad43fcbdd106ad5e"
535
+ },
536
+ {
537
+ "id": "20260916000100_agent_board_federated_identity_injective_slug",
538
+ "sha256": "d9f05e18094954f2e4ee32bf7b129f0a9ffc39bc800a5efa9cf7938e1555c8dc"
539
+ },
540
+ {
541
+ "id": "20260917000100_agent_board_invite_no_downgrade",
542
+ "sha256": "c205a9a679dca059979972b49c89c3b1b3b8f8e2039bd7675a10578f49f9b35a"
543
+ },
544
+ {
545
+ "id": "20260918000100_agent_board_signed_write_enforced_repoint",
546
+ "sha256": "f76dd01744029bfd0088bd4f6cf6fa5cb93e3c9b7fa5559ad83857cf7f4eb8ce"
547
+ },
548
+ {
549
+ "id": "20260919000100_agent_board_signed_write_model_key",
550
+ "sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
511
551
  }
512
552
  ]
513
553
  }
@@ -0,0 +1,89 @@
1
+ -- Consiliency/message-board#277 -- the ceiling lock COMMENT claimed a property
2
+ -- three of its four readers did not have, and the suite already said so.
3
+ --
4
+ -- WHAT WAS FALSE. 20260901000110:110 commented _lock_board_authority_ceiling as
5
+ --
6
+ -- every RPC that writes the ceiling and every RPC that reads it for
7
+ -- authorization takes this FIRST, before any boards row lock
8
+ --
9
+ -- while the catalog-derived class assertion in
10
+ -- agent_board_atomic_fleet_onboarding.test.sql enumerated the ceiling-authorization
11
+ -- readers from pg_proc and declared most of them UNSERIALIZED. The COMMENT is the
12
+ -- most discoverable statement of intent about this lock -- it is attached to the
13
+ -- lock itself -- so the next person reasoning about a ceiling race would have read
14
+ -- it and concluded their path was serialized while the suite said the opposite.
15
+ --
16
+ -- THIS IS A COMMENT FIX, AND ONLY A COMMENT FIX. The ceiling-ABSENCE race the old
17
+ -- text implied was closed is real for the UNSERIALIZED readers and was knowingly
18
+ -- left open (20260901000110's own header records the three it did not examine).
19
+ -- Nothing here acquires a lock, changes a body, or drops anything: this migration
20
+ -- contains one `comment on function` and no other statement, so no ACL and no other
21
+ -- COMMENT is disturbed.
22
+ --
23
+ -- WHY NOT SERIALIZE THE REMAINDER INSTEAD, which would have made the old prose
24
+ -- true. Each acquisition is a NEW edge in a lock graph that has already produced a
25
+ -- reproduced ABBA deadlock (#269 F4) and needed a fifth round of order analysis in
26
+ -- #276. Every reader would need its own total-order analysis and interleaving
27
+ -- proof; that is not a comment fix wearing a code hat, and a lock added to make
28
+ -- prose true is how deadlocks ship. Recorded here so the option is not re-litigated
29
+ -- as an oversight: it was considered and deliberately deferred per reader.
30
+ --
31
+ -- WHAT THE SET ACTUALLY IS, derived from pg_proc on a fresh replay of this bundle
32
+ -- and not from #277's issue text (which predates #276) or from memory:
33
+ --
34
+ -- readers of the ceiling for authorization (they call _grant_within_ceiling)
35
+ -- agent_board.grant_board_role(uuid,uuid,text,uuid) serialized
36
+ -- agent_board.onboard_federated_fleet_agent(...) serialized
37
+ -- agent_board._redeem_unbounded_invite_for_agent(text,uuid) UNSERIALIZED
38
+ -- agent_board.create_invite_link(...) UNSERIALIZED
39
+ -- writers of board_authority_ceilings
40
+ -- agent_board.set_board_authority_ceiling(uuid,text,uuid) serialized
41
+ --
42
+ -- grant_board_role joined the serialized side in #276 (20260909000100), which is
43
+ -- exactly why the issue's own list is already one entry stale: it says three of
44
+ -- four are UNSERIALIZED and two of four are.
45
+ --
46
+ -- HOW THIS COMMENT IS PREVENTED FROM ROTTING AGAIN -- the point of the exercise.
47
+ -- The two lists are written in a fixed machine-readable form,
48
+ --
49
+ -- WRITERS[serialized: <names>][UNSERIALIZED: <names>]
50
+ -- READERS[serialized: <names>][UNSERIALIZED: <names>]
51
+ --
52
+ -- and agent_board_atomic_fleet_onboarding.test.sql parses those blocks out of
53
+ -- obj_description() and compares them to the SAME pg_proc-derived classes its
54
+ -- existing registry assertion uses. A reader or writer added, removed, or moved
55
+ -- between serialized and UNSERIALIZED now fails an assertion that NAMES the
56
+ -- disagreement, and so does deleting or mangling the block (the parse yields NULL,
57
+ -- which cannot equal a catalog list). The prose around the blocks still has to be
58
+ -- read by a human, so it is kept to claims that have their own named pin:
59
+ -- * the setter takes the lock before its owner gate and its upsert
60
+ -- (agent_board_atomic_fleet_onboarding.test.sql + isolation spec
61
+ -- ceiling_setter_lock_before_upsert.spec);
62
+ -- * onboard_federated_fleet_agent takes it before its boards row lock
63
+ -- (same file + isolation spec onboarding_ceiling_lock_order.spec);
64
+ -- * grant_board_role takes no boards row lock at all, so it makes no ordering
65
+ -- claim -- checked against its live body, not assumed.
66
+ -- The old "before any boards row lock" was applied to every reader at once and had
67
+ -- a pin for exactly one of them. A single-word `serialized` that a test compares to
68
+ -- the catalog is worth more than a paragraph nothing checks.
69
+ --
70
+ -- NAMES, NOT SIGNATURES, in the blocks: onboard_federated_fleet_agent has 23
71
+ -- parameters and a signature list would be unreadable. Bare names are only an
72
+ -- identity while no two class members share a name, so the test asserts that
73
+ -- separately (as it already does for this helper's overload count).
74
+ --
75
+ -- KNOWN SIBLING, NOT FIXED HERE. set_board_authority_ceiling's live body still
76
+ -- carries the same overclaim as an inline comment: "Same board-keyed advisory lock
77
+ -- every ceiling READER for authorization takes." Correcting it means recreating a
78
+ -- SECURITY DEFINER function -- a far wider blast radius than this migration's --
79
+ -- to change prose, so it is left to a deliberate decision instead of being smuggled
80
+ -- in here. It is recorded POSITIVELY as a failing-when-fixed assertion in
81
+ -- agent_board_atomic_fleet_onboarding.test.sql rather than as a note in a PR body,
82
+ -- so it cannot be quietly lost.
83
+
84
+ comment on function agent_board._lock_board_authority_ceiling(uuid) is
85
+ 'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row. Held to commit. '
86
+ 'WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] -- the RPCs that write board_authority_ceilings. set_board_authority_ceiling takes this lock before its owner gate and its upsert, and therefore before the boards row that upsert reaches through board_authority_ceilings'' FK on INSERT. A direct table write by service_role bypasses every function-level lock (deliberate, 20260717000400). '
87
+ 'READERS[serialized: grant_board_role, onboard_federated_fleet_agent][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] -- the RPCs that read the ceiling for authorization through _grant_within_ceiling. onboard_federated_fleet_agent takes this lock BEFORE its boards row lock, because the writer reaches boards through that FK and the two orders must agree (inverted, they deadlocked). grant_board_role takes no boards row lock, so it makes no ordering claim. The UNSERIALIZED readers evaluate the ceiling WITHOUT serializing against its absence: a concurrent set_board_authority_ceiling can commit a first ceiling row after such a reader observed none, and it passes as default-owner. That race is real, known and accepted (#277); it is deliberately NOT closed by adding acquisitions, since each new edge in this lock graph has already produced a reproduced ABBA deadlock once (#269 F4). '
88
+ 'Both lists are machine-compared against pg_proc by agent_board_atomic_fleet_onboarding.test.sql, which fails when this COMMENT and the catalog disagree -- do not edit a list by hand without the catalog. '
89
+ 'Internal: it takes a lock with NO authorization check on an arbitrary board id, so it must never be grantable to authenticated.';
@@ -0,0 +1,330 @@
1
+ -- Consiliency/message-board#280 — revoked means revoked: a fleet-revoked agent cannot create a
2
+ -- board either.
3
+ --
4
+ -- THE RATIFIED DECISION, and what it overturns. #276 (20260909000100) gated the three board
5
+ -- authority chokepoints and both invite halves, and DELIBERATELY left `create_board` open. Its
6
+ -- argument was narrow and sound: creating a fresh board confers nothing on anyone else, takes
7
+ -- nothing from an existing board, and the ratified criterion for #276 spoke about GRANTING board
8
+ -- roles — and here there is no grantee, because the board did not exist a moment earlier. The
9
+ -- owner has since decided that the simpler operator-facing rule is worth more than that
10
+ -- distinction. A fleet-revoked principal creates nothing. This migration implements the
11
+ -- decision, not the debate.
12
+ --
13
+ -- THIS SUPERSEDES the "NAMED RESIDUAL (#280)" paragraph in 20260909000100's header. That file's
14
+ -- BYTES ARE NOT EDITED HERE, and that is a rule rather than a preference: #264's release gate
15
+ -- keys on the manifest's `aggregateSha256`, which moves for a byte edit to a shipped forward
16
+ -- migration — so amending 20260909000100's prose would make the published 1.28.0 artifact stop
17
+ -- being the bytes at 1.28.0, which is the exact failure #264 exists to catch. The record is
18
+ -- corrected forward, here, and the pgTAP file's RESIDUAL assertions are flipped in the same
19
+ -- commit so the change is behaviour rather than commentary.
20
+ --
21
+ -- ONE MECHANISM, NOT A SECOND. The gate is #276's `_assert_actor_not_fleet_revoked` — same
22
+ -- helper, same predicate (the existence of an agent-subject `fleet_revocation_events` row), same
23
+ -- 42501 with the `fleet_revoked:` prefix, same share lock. Every judgement recorded in
24
+ -- 20260909000100 carries over unchanged and is deliberately NOT re-litigated here: agent-wide
25
+ -- and binding-blind (JUDGEMENT 1), no supersession clause, and a principal that was never in a
26
+ -- fleet has no row and is unaffected by construction (JUDGEMENT 2). That last one is the
27
+ -- capability-regression risk on THIS function and it is the property most worth pinning, because
28
+ -- `create_board` is the one gated site that every ordinary user reaches: the new pgTAP section
29
+ -- asserts a NON-FLEET agent still creates a board and is still seeded its owner, POSITIVELY, on
30
+ -- both the authenticated and the service_role route.
31
+ --
32
+ -- ============================================================================
33
+ -- THE CLASS — create_board was ALREADY IN IT, listed as an exemption.
34
+ -- ============================================================================
35
+ -- #276's authority-write class is catalog-derived and matches ANY insert into
36
+ -- `board_memberships` (its memberships arm was deliberately made shape-blind BECAUSE
37
+ -- `create_board` seeds `role = 'owner'` with an INSERT that an UPDATE-shaped regex could not
38
+ -- see). So this function has always been a member of the class; it was carried as one of four
39
+ -- EXEMPTIONS with a positive disposition.
40
+ --
41
+ -- This migration moves it from exempt to gated, and the exemption's positive disposition is
42
+ -- REMOVED rather than left behind claiming something that is no longer true. The old EXEMPTION B
43
+ -- assertions (B1: it declares no role parameter; B2: the seat is the literal 'owner' for the
44
+ -- board's own created_by_agent_id; B3: the slug-collision branch cannot elevate a membership on
45
+ -- a pre-existing board) all remain TRUE — but they exist to license an exemption that no longer
46
+ -- exists, and a live assertion that licenses nothing is the shape this repo has been bitten by.
47
+ -- They are deleted, the exemption list drops from four to three, and the ledger-consult set
48
+ -- grows from five to six. Both sets are derived from pg_catalog on each run, so the two edits
49
+ -- must agree with the shipped schema or the difference NAMES create_board.
50
+ --
51
+ -- ============================================================================
52
+ -- LOCK ORDER — stated in full, and checked against #276 and #269 by name.
53
+ -- ============================================================================
54
+ -- The gate is placed at the point where BOTH branches converge, immediately before
55
+ -- `return query`, i.e. AFTER every write. That is the same placement, for the same reason, as
56
+ -- `set_board_authority_ceiling`, `set_board_role_gate` and `create_invite_link`.
57
+ --
58
+ -- create_board, FRESH-BOARD branch
59
+ -- boards(INSERT, a row this transaction created)
60
+ -- -> board_memberships(INSERT; FK KEY SHARE on agents(actor) and on the new boards row)
61
+ -- -> agents(SHARE)
62
+ -- create_board, SLUG-COLLISION branch
63
+ -- boards(FOR UPDATE, a pre-existing row)
64
+ -- -> agents(SHARE)
65
+ --
66
+ -- Both are boards-before-agents, which is the canonical order (#194 established boards before
67
+ -- auth.users before agents after a reproduced `deadlock detected`, and
68
+ -- onboard_federated_fleet_agent takes advisory -> boards -> auth.users -> agents ->
69
+ -- board_memberships -> agents -> agent_signing_keys). Every order above is a subsequence of that.
70
+ --
71
+ -- HOISTING THE GATE IS THE ABBA, and it is #269 F4's exact pair. Placed after actor resolution
72
+ -- and before the slug read, the collision branch would become agents(SHARE) -> boards(FOR
73
+ -- UPDATE): one session inside onboarding holds a `boards` row and wants `agents`, while a
74
+ -- concurrent `create_board` holds that `agents` row at SHARE and wants that `boards` row at FOR
75
+ -- UPDATE. SHARE and FOR UPDATE conflict, so both sides genuinely wait. That is the cycle #269
76
+ -- reproduced as `deadlock detected` and #276 refused to re-create; the placement here is
77
+ -- therefore load-bearing and must not be "tidied" to read more naturally.
78
+ --
79
+ -- Correctness is unaffected by the position: the function is one transaction, so the raise rolls
80
+ -- back the boards INSERT (or UPDATE), the membership seat and the emitted membership event. A
81
+ -- refused caller never sees a board id and no `owner_seed` event survives. This is #269's and
82
+ -- #276's ratified argument for the identical placement, not a new one.
83
+ --
84
+ -- WHAT THE FOR SHARE ACTUALLY BUYS, per branch, measured rather than claimed — the same honesty
85
+ -- 20260909000100 applied to the mint path:
86
+ --
87
+ -- * FRESH-BOARD branch: nothing. `board_memberships_agent_id_fkey` (and
88
+ -- `board_memberships_created_by_agent_id_fkey`) make the seat INSERT take FOR KEY SHARE on
89
+ -- the actor's `agents` row, and that insert PRECEDES the gate. KEY SHARE already conflicts
90
+ -- with `revoke_fleet_subject`'s FOR UPDATE, so the serialization on this path is supplied by
91
+ -- the FK; by the time the gate runs, this transaction already holds the row and upgrading
92
+ -- KEY SHARE to SHARE adds no new wait. The isolation permutations for this branch pin the
93
+ -- OUTCOME (it blocks, then fails closed, leaving no board and no seat) and CANNOT pin this
94
+ -- lock, because the FK acquisition always precedes the gate.
95
+ -- * SLUG-COLLISION branch: everything. The `boards` UPDATE sets name/description/status/
96
+ -- visibility/metadata/updated_at and does NOT touch `created_by_agent_id`, so no FK trigger
97
+ -- fires and no `agents` lock is taken anywhere else in that branch. The gate's FOR SHARE is
98
+ -- the FIRST and ONLY `agents` lock on that path, which makes it the genuine serialization
99
+ -- point — and gives this migration the lock discriminator the mint path could not provide.
100
+ -- `ledger_authority_revoke.spec` permutation 11 is that discriminator: with the FOR SHARE
101
+ -- removed, the reopen reads its own snapshot and SUCCEEDS instead of blocking.
102
+ --
103
+ -- CHECKED BY NAME against the functions #269 F4 and #276 named:
104
+ -- * revoke_fleet_subject — fleet_machines -> agents -> fleet_machine_agents ->
105
+ -- fleet_revocation_events. It touches neither `boards` nor `board_memberships`, so it shares
106
+ -- no second operand with either branch above: it can only be WAITED ON, which is the point.
107
+ -- * _revoke_agent_principal — agents -> agent_signing_keys. Same: no boards, no memberships.
108
+ -- * onboard_federated_fleet_agent — advisory -> boards -> auth.users -> agents ->
109
+ -- board_memberships -> agents -> agent_signing_keys. Both branches above are subsequences.
110
+ -- * enroll_federated_identity — takes `boards FOR KEY SHARE` (20260903000100:112-114, hoisted
111
+ -- there by #194 to fix a reproduced deadlock) before it reaches any membership work. Again
112
+ -- boards-before-agents; create_board does not invert against it.
113
+ --
114
+ -- ============================================================================
115
+ -- THE agents/board_memberships CYCLE CLASS (signer_lifecycle section 9) — WHY create_board
116
+ -- DOES NOT JOIN IT, ASSERTED RATHER THAN ASSUMED.
117
+ -- ============================================================================
118
+ -- #276 discovered, the hard way, that adding this same gate to `grant_board_role` put it in the
119
+ -- class that section 9 pins from the call graph — every function row-locking BOTH `agents` and
120
+ -- `board_memberships` must take the board advisory lock — and the assertion FAILED and named it.
121
+ -- The obvious question here is whether create_board now joins that class too. It does not, and
122
+ -- the reason is substantive rather than a regex accident:
123
+ --
124
+ -- * create_board's ONLY `board_memberships` write is the owner seat, and it happens ONLY on
125
+ -- the fresh-board branch — against a `boards` row this same transaction inserted moments
126
+ -- earlier. No other session can hold, see or contend for a membership row on a board that
127
+ -- does not exist outside this transaction, so create_board cannot be one end of the
128
+ -- (agents, board_memberships) cycle F4 reproduced. The `on conflict` clause is defensive and
129
+ -- structurally unreachable on a board with no memberships.
130
+ -- * And it COULD NOT take the advisory lock even if the class demanded it. That lock is keyed
131
+ -- on the board id, which on the fresh path does not exist until after the `boards` INSERT —
132
+ -- so taking it would give boards -> advisory, inverting against
133
+ -- onboard_federated_fleet_agent's advisory -> boards. The fix would BE the cycle, which is
134
+ -- the shape #276 rejected for the redeem path.
135
+ --
136
+ -- Section 9's assertion is left unchanged and re-run: it must still report exactly three members
137
+ -- (grant_board_role, onboard_federated_fleet_agent, signed_grant_board_role). That it stays
138
+ -- green is CHECKED after this migration is applied, not inferred from this paragraph.
139
+ --
140
+ -- ============================================================================
141
+ -- SCOPE — THE COLLISION BRANCH IS GATED TOO, and that is more than "board creation".
142
+ -- ============================================================================
143
+ -- Recorded explicitly rather than shipped quietly. `create_board` is two operations behind one
144
+ -- name: it creates a board, and — when the slug already exists AND the actor holds an ACTIVE
145
+ -- owner membership on it — it renames/reopens/re-visibilities that existing board. The second
146
+ -- branch writes NO authority relation and is therefore not in the class this issue is about.
147
+ --
148
+ -- The gate at the convergence point covers both, deliberately:
149
+ -- * The ratified rule is a rule about a PRINCIPAL, not about a syntax. "A fleet-revoked agent
150
+ -- cannot create a board, but can still flip an existing board to `visibility = 'open'` and
151
+ -- `status = 'active'`" is precisely the conditional, hard-to-state behaviour the owner's
152
+ -- decision replaced.
153
+ -- * Reopening an archived board and making it publicly visible is an operator act on a
154
+ -- resource that DOES already exist and DOES already have other members — materially closer
155
+ -- to the invite half #276 closed than to the "confers nothing on anyone else" argument that
156
+ -- licensed the original exemption.
157
+ -- A revoked NON-owner colliding on a slug still gets the pre-existing `board slug already in
158
+ -- use` 42501 from the owner check, which runs first; the fleet_revoked refusal is reachable only
159
+ -- by the actor who would otherwise have been allowed through.
160
+ --
161
+ -- ============================================================================
162
+ -- BLAST RADIUS — create_board is `authenticated`-callable and on a live path.
163
+ -- ============================================================================
164
+ -- IN-DATABASE CALLERS: NONE. Swept from pg_catalog on the shipped schema, over every namespace
165
+ -- and all three carriers of SQL — `pg_proc.prosrc ~ '\mcreate_board\s*\('` (excluding the
166
+ -- function itself), `pg_views.definition`, and `pg_trigger` — exactly zero referrers. So unlike
167
+ -- grant_board_role (whose signed wrapper inherits the gate by construction) there is no second
168
+ -- in-schema route to keep in step, and no in-DB caller can be broken by this change.
169
+ --
170
+ -- OUT-OF-DATABASE CALLERS, enumerated and unchanged by this migration — the signature, the
171
+ -- return shape and the ACL are all identical, so nothing needs a client release:
172
+ -- * SDK AgentBoardClient.createBoard (packages/agent-board-client/src/client.ts) — posts
173
+ -- to the `create_board` RPC; `create_board` is in the client's actor-argument map
174
+ -- and its M-posture table, neither of which changes.
175
+ -- * MCP the `agent_board.create_board` tool (packages/agent-board-mcp/src/tools.ts).
176
+ -- * CLI the standalone board command, AND the enroll flow's DEFAULT post-enroll intent
177
+ -- (`kind: "create_board"` with the agent's own slug —
178
+ -- packages/agent-board-client/src/enroll_command.ts).
179
+ --
180
+ -- THE ONE LIVE CONSEQUENCE WORTH NAMING, because a subscriber should read it here and not
181
+ -- discover it: a FLEET-REVOKED agent that re-runs enroll now gets a durable enrollment and then
182
+ -- a `fleet_revoked:` failure at the default board-create step. Enrollment itself is untouched.
183
+ -- That is the decision working exactly as ratified — a revoked principal ends up enrolled and
184
+ -- boardless rather than enrolled and owning a fresh namespace — but it is a behaviour change on
185
+ -- a path every CLI user hits, and it is the item for an adopter's migration checklist. A
186
+ -- never-revoked agent, and any principal that was never in a fleet at all, is unaffected: there
187
+ -- is no ledger row to find (JUDGEMENT 2), and the pgTAP file asserts that positively rather than
188
+ -- by the absence of a refusal.
189
+ --
190
+ -- CREATE OR REPLACE preserves the ACL and the COMMENT, but both are restated below anyway — the
191
+ -- behaviour changed, so the comment must, and this repo has been bitten by a recreation that
192
+ -- silently re-granted PUBLIC.
193
+
194
+ -- ---------------------------------------------------------------------------
195
+ -- create_board — verbatim from 20260807000100:272 plus ONE line: the #276 gate at the point
196
+ -- where both branches converge. Nothing else in the body changes.
197
+ -- ---------------------------------------------------------------------------
198
+ create or replace function agent_board.create_board(p_created_by_agent_id uuid, p_slug text, p_name text, p_description text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_visibility text DEFAULT 'private'::text)
199
+ RETURNS TABLE(board_id uuid, slug text, name text, status text, visibility text, created_at timestamp with time zone, updated_at timestamp with time zone)
200
+ LANGUAGE plpgsql
201
+ SECURITY DEFINER
202
+ SET search_path TO 'agent_board', 'public'
203
+ as $$
204
+ #variable_conflict use_column
205
+ declare
206
+ v_seed_state text;
207
+ v_actor uuid;
208
+ v_existing agent_board.boards%rowtype;
209
+ v_board agent_board.boards%rowtype;
210
+ begin
211
+ if nullif(trim(p_slug), '') is null or nullif(trim(p_name), '') is null then
212
+ raise exception 'board slug and name are required' using errcode = '22023';
213
+ end if;
214
+ if coalesce(p_visibility, 'private') not in ('private', 'invite_only', 'open', 'directory_hidden') then
215
+ raise exception 'invalid board visibility' using errcode = '22023';
216
+ end if;
217
+
218
+ -- Effective actor: service_role -> supplied arg (create-on-behalf-of);
219
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
220
+ v_actor := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
221
+
222
+ if not exists (select 1 from agent_board.agents where id = v_actor) then
223
+ raise exception 'agent not found' using errcode = 'P0002';
224
+ end if;
225
+
226
+ select * into v_existing
227
+ from agent_board.boards b
228
+ where b.slug = trim(p_slug);
229
+
230
+ if found then
231
+ -- Slug-collision path is OWNER-ONLY, keyed on CURRENT authority: the actor must
232
+ -- hold an ACTIVE owner-role membership on the existing board — NOT merely be its
233
+ -- original `created_by_agent_id`. A demoted or departed original creator no longer
234
+ -- owns the board and must NOT be able to re-open/rename/flip-visibility it.
235
+ if not exists (
236
+ select 1 from agent_board.board_memberships m
237
+ where m.board_id = v_existing.id
238
+ and m.agent_id = v_actor
239
+ and m.role = 'owner'
240
+ and m.membership_state in ('joined', 'subscribed')
241
+ ) then
242
+ raise exception 'board slug already in use' using errcode = '42501';
243
+ end if;
244
+
245
+ update agent_board.boards as b
246
+ set name = trim(p_name),
247
+ description = nullif(trim(p_description), ''),
248
+ status = 'active',
249
+ visibility = coalesce(p_visibility, 'private'),
250
+ metadata = coalesce(b.metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb),
251
+ updated_at = now()
252
+ where b.id = v_existing.id
253
+ returning * into v_board;
254
+ -- No owner re-seed: the owner was seeded at this board's original creation, and
255
+ -- re-promoting on every create_board would resurrect a deliberately demoted owner.
256
+ else
257
+ insert into agent_board.boards as b(slug, name, description, created_by_agent_id, visibility, metadata)
258
+ values (
259
+ trim(p_slug),
260
+ trim(p_name),
261
+ nullif(trim(p_description), ''),
262
+ v_actor,
263
+ coalesce(p_visibility, 'private'),
264
+ coalesce(p_metadata, '{}'::jsonb)
265
+ )
266
+ returning * into v_board;
267
+
268
+ -- Seed the creator's owner membership, bound to the board's ACTUAL
269
+ -- created_by_agent_id (never the raw param). Idempotent against the active
270
+ -- partial-unique index so at most one active owner row exists.
271
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
272
+ values (v_board.id, v_board.created_by_agent_id, 'joined', 'owner', v_board.created_by_agent_id)
273
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
274
+ do update set role = 'owner', updated_at = now()
275
+ returning bm.membership_state into v_seed_state;
276
+ -- The creator seeds their OWN owner seat, so the session is the subject and this emits
277
+ -- target-authored. Under service_role it emits operator-designated instead -- correct
278
+ -- either way, because the helper reads the session rather than being told.
279
+ --
280
+ -- v_seed_state, NOT a literal 'joined'. The ON CONFLICT above lifts only the ROLE, so a
281
+ -- board whose creator already holds a `subscribed` row stays subscribed while a literal
282
+ -- would record `joined` -- a ledger contradicting the table. Same defect the redemption
283
+ -- path had; fixing one instance without sweeping the class is how it survived here.
284
+ perform agent_board._emit_membership_event(v_board.id, v_board.created_by_agent_id, v_seed_state, 'owner_seed');
285
+ end if;
286
+
287
+ -- #280. Revoked means revoked: a fleet-revoked agent creates nothing. #276 gated the three
288
+ -- authority chokepoints and both invite halves and left this function open on a stated
289
+ -- argument (a fresh board confers nothing on anyone else); the owner overturned that in favour
290
+ -- of the simpler rule, and this line is the whole of the change.
291
+ --
292
+ -- SITED HERE, at the convergence of both branches and AFTER every write, for the lock order.
293
+ -- Fresh branch: boards(insert) -> board_memberships(insert, FK KEY SHARE on agents) ->
294
+ -- agents(SHARE). Collision branch: boards(FOR UPDATE) -> agents(SHARE). Both are
295
+ -- boards-before-agents, the canonical order. HOISTED above the slug read it would become
296
+ -- agents(SHARE) -> boards(FOR UPDATE) on the collision branch, which is #269 F4's exact ABBA
297
+ -- against onboard_federated_fleet_agent's boards -> agents. One transaction, so this raise
298
+ -- rolls back the board, the owner seat and the owner_seed event alike -- position costs
299
+ -- nothing in correctness and everything in deadlock safety.
300
+ --
301
+ -- On the fresh branch the FK on the seat insert has ALREADY taken KEY SHARE on this row, so
302
+ -- the FOR SHARE is a free upgrade and is NOT what serializes that path (see the header). On
303
+ -- the collision branch it is the only agents lock there is, and it is the serialization point.
304
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'create_board');
305
+
306
+ return query
307
+ select v_board.id, v_board.slug, v_board.name, v_board.status, v_board.visibility, v_board.created_at, v_board.updated_at;
308
+ end;
309
+ $$;
310
+
311
+ comment on function agent_board.create_board(uuid, text, text, text, jsonb, text) is
312
+ 'Creates a board and seeds the resolved actor''s owner membership; on a slug COLLISION it '
313
+ 'instead renames/reopens/re-visibilities the existing board, but only for an actor holding an '
314
+ 'ACTIVE owner-role membership on it (else 42501 ''board slug already in use''), and it never '
315
+ 're-seeds an owner. Actor is _resolved_actor_agent_id: service_role uses the supplied arg '
316
+ '(create-on-behalf-of), authenticated uses current_agent_id() and fails closed on mismatch. '
317
+ '#280: the resolved actor is then checked against the fleet revocation ledger by '
318
+ '_assert_actor_not_fleet_revoked -- an agent carrying an agent-subject fleet_revocation_events '
319
+ 'row cannot create a board at all (42501, ''fleet_revoked:''), the same answer '
320
+ 'evaluate_fleet_sender_policy already gives it on the send path and #276 gave it on the five '
321
+ 'authority routes. This CLOSES the residual #276 named and left open. The gate covers BOTH '
322
+ 'branches by design, so a revoked owner also cannot reopen or re-visibility an existing board. '
323
+ 'It is placed AFTER the writes so the lock order stays boards -> agents (fresh branch: '
324
+ 'boards -> board_memberships -> agents); hoisting it would give agents -> boards on the '
325
+ 'collision branch and invert against onboard_federated_fleet_agent, the ABBA #269 reproduced. '
326
+ 'The function is one transaction, so the raise rolls back the board, the owner seat and the '
327
+ 'owner_seed event. A principal with no fleet binding has no ledger row and is unaffected.';
328
+
329
+ revoke all on function agent_board.create_board(uuid, text, text, text, jsonb, text) from public, anon;
330
+ grant execute on function agent_board.create_board(uuid, text, text, text, jsonb, text) to authenticated, service_role;