@consiliency/agent-board-schema 1.12.0 → 1.22.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 (104) hide show
  1. package/fixtures/already-applied.json +124 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/package.json +1 -1
  34. package/schema-manifest.json +282 -3
  35. package/src/tooling.test.mjs +113 -2
  36. package/tests/agent_board_actorspoof.test.sql +349 -0
  37. package/tests/agent_board_agentsdir.test.sql +154 -0
  38. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  39. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  40. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  41. package/tests/agent_board_claimkey.test.sql +229 -0
  42. package/tests/agent_board_consent_backfill.test.sql +48 -0
  43. package/tests/agent_board_consent_emission.test.sql +601 -0
  44. package/tests/agent_board_consent_gating.test.sql +489 -0
  45. package/tests/agent_board_consent_substrate.test.sql +720 -0
  46. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  47. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  48. package/tests/agent_board_delegation_policies.test.sql +107 -1
  49. package/tests/agent_board_fedsession.test.sql +6 -4
  50. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  51. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  52. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  53. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  54. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  55. package/tests/agent_board_fleet_policy.test.sql +2 -2
  56. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  57. package/tests/agent_board_identity_providers.test.sql +9 -0
  58. package/tests/agent_board_inbox_reads.test.sql +29 -12
  59. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  60. package/tests/agent_board_invitepolicy.test.sql +205 -0
  61. package/tests/agent_board_leaveidem.test.sql +194 -0
  62. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  63. package/tests/agent_board_mint_harden.test.sql +303 -0
  64. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  65. package/tests/agent_board_obsgate.test.sql +178 -0
  66. package/tests/agent_board_obsgate2.test.sql +153 -0
  67. package/tests/agent_board_obsgate3.test.sql +170 -0
  68. package/tests/agent_board_obsgate4.test.sql +196 -0
  69. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  70. package/tests/agent_board_pubexec_class.test.sql +53 -0
  71. package/tests/agent_board_rejectsem.test.sql +271 -0
  72. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  73. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  74. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  75. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  76. package/tests/agent_board_rolereset.test.sql +351 -0
  77. package/tests/agent_board_roomdeleg.test.sql +5 -3
  78. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  79. package/tests/agent_board_roomlink.test.sql +25 -9
  80. package/tests/agent_board_rowid.test.sql +1274 -0
  81. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  82. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  83. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  84. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  85. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  86. package/tests/agent_board_signing_store.test.sql +6 -6
  87. package/tests/fixtures/isolation-substrate.sql +93 -0
  88. package/tests/fixtures/test-prereqs.sql +33 -0
  89. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  90. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  91. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  92. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  93. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  94. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  95. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  96. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  97. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  98. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  99. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  100. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  101. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  102. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  103. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  104. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,145 @@
1
+ -- =============================================================================
2
+ -- MBCONSENT-B(i) — REJECTSEM: a refusal is not an eviction (message-board#89)
3
+ -- =============================================================================
4
+ -- Reproduced on main before designing the fix, as the phase plan requires:
5
+ -- subject requests to join an OPEN board -> owner rejects with `removed`
6
+ -- -> subject can no longer join it: "board is not joinable by this agent"
7
+ -- One refusal was a permanent, self-unrecoverable bar on a board open to anyone.
8
+ --
9
+ -- resolve_board_request is the REFUSAL path, not an eviction path -- update_board_membership
10
+ -- is the schema's only eviction path. So `removed` from this RPC always mislabelled a refusal
11
+ -- as a removal. It now writes `left`, which is recoverable.
12
+ --
13
+ -- CORRECTED CLAIM, and the reason it matters. An earlier draft of this header asserted that
14
+ -- this RPC "only ever acts on a pending row". That is FALSE, and review caught it: the
15
+ -- resolver reads the pending row, but the shared sink independently reselects with
16
+ -- ACTIVE-ROW PREFERENCE and does not revalidate that its pick is still pending. With a legal
17
+ -- active-plus-pending duplicate -- permitted, because the uniqueness index covers only ACTIVE
18
+ -- rows -- rejecting the pending request downgrades the ACTIVE membership instead.
19
+ --
20
+ -- That defect is PRE-EXISTING and not introduced here: before this change the same sequence
21
+ -- set the active row to `removed`, which is strictly worse because `removed` also bars
22
+ -- re-entry. It is filed separately and belongs with the locking work, which will make the
23
+ -- resolver select and lock its own row rather than delegating the choice to the sink. This
24
+ -- migration does not claim to fix it.
25
+ --
26
+ -- The API is unchanged: callers still pass `removed` to reject. The rewrite is server-side, so
27
+ -- no client needs to know -- and a client that passed `left` instead would be REFUSED, because
28
+ -- this RPC's enum does not accept it.
29
+ --
30
+ -- The refusal remains fully durable in the ledger: this changes the membership STATE, not the
31
+ -- record that a rejection happened. schema_version stays 8.
32
+ -- =============================================================================
33
+
34
+ create or replace function agent_board.resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
35
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
36
+ LANGUAGE plpgsql
37
+ SECURITY DEFINER
38
+ SET search_path TO 'agent_board', 'public'
39
+ as $$
40
+ declare
41
+ v_effective_state text;
42
+ v_board agent_board.boards%rowtype;
43
+ v_existing_state text;
44
+ v_membership agent_board.board_memberships%rowtype;
45
+ v_actor uuid := agent_board.current_agent_id();
46
+ begin
47
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
48
+ if v_actor is null and not agent_board._is_service_role() then
49
+ raise exception 'current agent principal not resolved' using errcode = '42501';
50
+ end if;
51
+
52
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
53
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
54
+ end if;
55
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
56
+ if v_board.id is null then
57
+ raise exception 'board not found' using errcode = 'P0002';
58
+ end if;
59
+ if v_actor is not null then
60
+ if v_board.role_gate_enforced then
61
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
62
+ -- must hold an active owner/admin membership.
63
+ if not exists (
64
+ select 1
65
+ from agent_board.board_memberships bm
66
+ where bm.board_id = p_board_id
67
+ and bm.agent_id = v_actor
68
+ and bm.membership_state in ('joined', 'subscribed')
69
+ and bm.role in ('owner', 'admin')
70
+ ) then
71
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
72
+ end if;
73
+ elsif v_actor <> v_board.created_by_agent_id then
74
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
75
+ if not exists (
76
+ select 1
77
+ from agent_board.board_memberships bm
78
+ where bm.board_id = p_board_id
79
+ and bm.agent_id = v_actor
80
+ and bm.membership_state in ('joined', 'subscribed')
81
+ ) then
82
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
83
+ end if;
84
+ end if;
85
+ end if;
86
+
87
+ select bm.membership_state
88
+ into v_existing_state
89
+ from agent_board.board_memberships bm
90
+ where bm.board_id = p_board_id
91
+ and bm.agent_id = p_agent_id
92
+ order by bm.created_at desc, bm.id desc
93
+ limit 1;
94
+
95
+ -- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
96
+ -- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
97
+ -- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
98
+ -- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
99
+ if coalesce(v_existing_state, '') not in ('requested', 'invited') then
100
+ raise exception 'pending board membership not found' using errcode = 'P0002';
101
+ end if;
102
+
103
+ -- REJECTSEM (#89). A rejection is NOT an eviction. resolve_board_request only ever acts on a
104
+ -- PENDING row (requested|invited) -- update_board_membership is the schema's only eviction
105
+ -- path -- so writing `removed` here mislabels a refusal as a removal, and `removed` bars
106
+ -- self-re-entry. Reproduced before fixing: request an OPEN board, owner rejects, and the
107
+ -- subject can no longer join a board open to anyone ("board is not joinable by this agent").
108
+ -- One refusal became a permanent, self-unrecoverable bar.
109
+ --
110
+ -- Applied to BOTH pending origins, not just `requested`. Cancelling an INVITATION had the
111
+ -- identical trap, and fixing only the named instance is how several defects in this work
112
+ -- survived a round.
113
+ --
114
+ -- `left` is recoverable: the subject may act again. The refusal is still durable in the
115
+ -- ledger -- this changes the membership STATE, not the record that it happened.
116
+ v_effective_state := case when p_membership_state = 'removed' then 'left'
117
+ else p_membership_state end;
118
+
119
+ v_membership := agent_board._upsert_board_membership_state(
120
+ p_board_id,
121
+ p_agent_id,
122
+ v_effective_state,
123
+ p_created_by_agent_id,
124
+ p_metadata
125
+ );
126
+
127
+ -- NOT relabelled here, deliberately. An earlier revision UPDATEd the freshly-emitted
128
+ -- ledger row to `reject_withdrawn`, so the history would say a REFUSAL rather than an
129
+ -- eviction. It worked -- and that is the problem. board_membership_events is append-only by
130
+ -- GRANT: service_role holds SELECT and INSERT and no UPDATE. The write succeeded only
131
+ -- because this function is SECURITY DEFINER and runs as the owner, bypassing grants
132
+ -- entirely. Fixing a descriptive label by punching through the property that makes the
133
+ -- ledger trustworthy is a worse trade than the label being wrong.
134
+ --
135
+ -- The row must instead be written with the right kind the FIRST time, which means threading
136
+ -- a kind through the shared sink. That changes a signature every membership route calls, so
137
+ -- it belongs in the gating change that is already modifying the sink -- reviewed in context,
138
+ -- not bolted onto this one. Filed; until then a refusal records as `eviction`. The membership
139
+ -- STATE is correct and recoverable, which is what #89 is about; only the label is wrong.
140
+
141
+
142
+ return query
143
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
144
+ end;
145
+ $$;
@@ -0,0 +1,492 @@
1
+ -- =============================================================================
2
+ -- MBCONSENT-B(ii) — ROUTE GATING (message-board#82) + the paired widening (#108)
3
+ -- =============================================================================
4
+ -- The change that actually closes the hole. Everything before it was preparation: the ledger
5
+ -- exists (#101), every writer records to it (#104), and a refusal no longer bans (#107).
6
+ -- Nothing read the record. This makes it authoritative.
7
+ --
8
+ -- This migration is NOT inert. Calls that succeed today begin failing -- that is the point --
9
+ -- so every gate ships with a paired assertion that the legitimate path still works. A gate
10
+ -- that refuses everything passes every force-join test.
11
+ --
12
+ -- FIVE user-callable routes reach an active membership, not four. Every prior document said
13
+ -- four; redeem_invite_link was missed because it reaches membership through a different
14
+ -- internal function. It needs NO change here -- verified: it takes no target parameter and
15
+ -- passes current_agent_id(), so it is already self-only and cannot name a victim.
16
+ --
17
+ -- join_board creator exemption REMOVED (#82) + invitation admits on any
18
+ -- visibility (#108)
19
+ -- resolve_board_request promotion to active requires consent on the ledger
20
+ -- update_board_membership other-agent promotion into active refused
21
+ -- redeem_invite_link unchanged -- already self-only
22
+ -- create_board unchanged -- the actor IS the subject
23
+ --
24
+ -- NOT gated, verified rather than assumed: invite_board_member writes only `invited`,
25
+ -- request_join_board only `requested`, leave_board only `left`, and grant_board_role requires
26
+ -- an already-active membership and never SETs membership_state.
27
+ --
28
+ -- THE OPERATOR CARVE-OUT IS PRESERVED AND IS DELIBERATE. Every gate is conditioned on
29
+ -- `v_actor is not null` -- authenticated callers. service_role may still place agents, recorded
30
+ -- as operator-designated with a null actor and never laundered as consent. Writing the gates as
31
+ -- "authenticated is blocked" rather than "the actor must be the subject" would mean a caller
32
+ -- misclassified as operator OPENS the hole rather than closing it.
33
+ --
34
+ -- KNOWN LIMITATION, stated so the gate is not mistaken for airtight: both admission paths
35
+ -- CHECK CONSENT BEFORE THE ROW IS LOCKED. The sink locks only when it writes, so a subject who
36
+ -- withdraws between the check and the write can still be admitted -- a check-then-write race.
37
+ --
38
+ -- This narrows an open hole to a race window rather than leaving it open: before this
39
+ -- migration an owner could admit anyone unconditionally, with no race required. It is not
40
+ -- fully closed until the locking phase, which makes the resolver select and lock its own row
41
+ -- before reading consent. That phase also carries #109 (rejecting a pending request can
42
+ -- downgrade an ACTIVE membership, because the sink reselects with active-row preference) and
43
+ -- #110 (a refusal records as an eviction) -- all three are the same shared-function change and
44
+ -- are reviewed together rather than three passes over the same code.
45
+ --
46
+ -- schema_version stays 8: additive.
47
+ -- =============================================================================
48
+
49
+ create or replace function agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
50
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
51
+ LANGUAGE plpgsql
52
+ SECURITY DEFINER
53
+ SET search_path TO 'agent_board', 'public'
54
+ as $$
55
+ declare
56
+ v_board agent_board.boards%rowtype;
57
+ v_existing_state text;
58
+ v_membership agent_board.board_memberships%rowtype;
59
+ v_actor uuid := agent_board.current_agent_id();
60
+ begin
61
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
62
+ if v_actor is null and not agent_board._is_service_role() then
63
+ raise exception 'current agent principal not resolved' using errcode = '42501';
64
+ end if;
65
+
66
+ if p_membership_state not in ('joined', 'subscribed') then
67
+ raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
68
+ end if;
69
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
70
+ raise exception 'agent not found' using errcode = 'P0002';
71
+ end if;
72
+ if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
73
+ raise exception 'creator agent not found' using errcode = 'P0002';
74
+ end if;
75
+
76
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
77
+ if v_board.id is null then
78
+ raise exception 'board not found' using errcode = 'P0002';
79
+ end if;
80
+
81
+ -- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
82
+ -- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
83
+ -- locking an active member out of their own private / invite_only board (P0002).
84
+ select bm.membership_state
85
+ into v_existing_state
86
+ from agent_board.board_memberships bm
87
+ where bm.board_id = p_board_id
88
+ and bm.agent_id = p_agent_id
89
+ order by
90
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
91
+ bm.created_at desc,
92
+ bm.id desc
93
+ limit 1;
94
+
95
+ -- #82: the CREATOR EXEMPTION IS REMOVED. Previously a board's creator could join ANY agent
96
+ -- to their board -- `v_actor <> v_board.created_by_agent_id` let them past this guard, and
97
+ -- the joinability check below only runs for self-joins, so nothing else stopped them. That
98
+ -- is the force-join hole. Creating a board does not entitle you to place other people in it.
99
+ --
100
+ -- Only the subject may join the subject. service_role (v_actor null) keeps the operator
101
+ -- carve-out, which is deliberate and maintainer-confirmed.
102
+ if v_actor is not null and v_actor <> p_agent_id then
103
+ raise exception 'board membership actor mismatch' using errcode = '42501';
104
+ end if;
105
+
106
+ -- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
107
+ -- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
108
+ -- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
109
+ -- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
110
+ -- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
111
+ -- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
112
+ --
113
+ -- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
114
+ -- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
115
+ -- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
116
+ -- created_at can surface 'left' as the total-order winner while standing is still removed
117
+ -- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
118
+ if v_actor is not null
119
+ and v_actor = p_agent_id
120
+ and v_board.created_by_agent_id <> v_actor
121
+ and not (
122
+ not (
123
+ exists (
124
+ select 1 from agent_board.board_memberships bm
125
+ where bm.board_id = p_board_id
126
+ and bm.agent_id = p_agent_id
127
+ and bm.membership_state = 'removed'
128
+ ) and not exists (
129
+ select 1 from agent_board.board_memberships bm
130
+ where bm.board_id = p_board_id
131
+ and bm.agent_id = p_agent_id
132
+ and bm.membership_state in ('joined', 'subscribed')
133
+ )
134
+ )
135
+ and (
136
+ v_board.visibility = 'open'
137
+ -- #108: HOLDING AN INVITATION ADMITS THE SUBJECT ON ANY VISIBILITY, not only
138
+ -- invite_only. Previously `private` and `directory_hidden` were excluded outright, so
139
+ -- an invited subject had NO act available -- verified: a first-time invitee with no
140
+ -- prior refusal was blocked identically to a refused one. Gating makes consent
141
+ -- REQUIRED, so a board type where consent cannot be GIVEN is correct-but-unusable.
142
+ --
143
+ -- This is the narrowest widening that makes consent exercisable. It admits nobody new:
144
+ -- this whole guard is reached only when v_actor = p_agent_id (the actor-mismatch raise
145
+ -- above returns otherwise), so it is self-accept only. The board still decides who is
146
+ -- invited; the subject decides whether to accept.
147
+ or coalesce(v_existing_state, '') = 'invited'
148
+ or coalesce(v_existing_state, '') in ('joined', 'subscribed')
149
+ )
150
+ ) then
151
+ raise exception 'board is not joinable by this agent' using errcode = 'P0002';
152
+ end if;
153
+
154
+ v_membership := agent_board._upsert_board_membership_state(
155
+ p_board_id,
156
+ p_agent_id,
157
+ p_membership_state,
158
+ coalesce(p_created_by_agent_id, p_agent_id),
159
+ p_metadata
160
+ );
161
+
162
+ return query
163
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
164
+ end;
165
+ $$;
166
+
167
+ create or replace function agent_board.resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
168
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
169
+ LANGUAGE plpgsql
170
+ SECURITY DEFINER
171
+ SET search_path TO 'agent_board', 'public'
172
+ as $$
173
+ declare
174
+ v_effective_state text;
175
+ v_board agent_board.boards%rowtype;
176
+ v_existing_state text;
177
+ v_membership agent_board.board_memberships%rowtype;
178
+ v_actor uuid := agent_board.current_agent_id();
179
+ begin
180
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
181
+ if v_actor is null and not agent_board._is_service_role() then
182
+ raise exception 'current agent principal not resolved' using errcode = '42501';
183
+ end if;
184
+
185
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
186
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
187
+ end if;
188
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
189
+ if v_board.id is null then
190
+ raise exception 'board not found' using errcode = 'P0002';
191
+ end if;
192
+ if v_actor is not null then
193
+ if v_board.role_gate_enforced then
194
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
195
+ -- must hold an active owner/admin membership.
196
+ if not exists (
197
+ select 1
198
+ from agent_board.board_memberships bm
199
+ where bm.board_id = p_board_id
200
+ and bm.agent_id = v_actor
201
+ and bm.membership_state in ('joined', 'subscribed')
202
+ and bm.role in ('owner', 'admin')
203
+ ) then
204
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
205
+ end if;
206
+ elsif v_actor <> v_board.created_by_agent_id then
207
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
208
+ if not exists (
209
+ select 1
210
+ from agent_board.board_memberships bm
211
+ where bm.board_id = p_board_id
212
+ and bm.agent_id = v_actor
213
+ and bm.membership_state in ('joined', 'subscribed')
214
+ ) then
215
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
216
+ end if;
217
+ end if;
218
+ end if;
219
+
220
+ select bm.membership_state
221
+ into v_existing_state
222
+ from agent_board.board_memberships bm
223
+ where bm.board_id = p_board_id
224
+ and bm.agent_id = p_agent_id
225
+ order by bm.created_at desc, bm.id desc
226
+ limit 1;
227
+
228
+ -- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
229
+ -- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
230
+ -- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
231
+ -- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
232
+ if coalesce(v_existing_state, '') not in ('requested', 'invited') then
233
+ raise exception 'pending board membership not found' using errcode = 'P0002';
234
+ end if;
235
+
236
+ -- REJECTSEM (#89). A rejection is NOT an eviction. resolve_board_request only ever acts on a
237
+ -- PENDING row (requested|invited) -- update_board_membership is the schema's only eviction
238
+ -- path -- so writing `removed` here mislabels a refusal as a removal, and `removed` bars
239
+ -- self-re-entry. Reproduced before fixing: request an OPEN board, owner rejects, and the
240
+ -- subject can no longer join a board open to anyone ("board is not joinable by this agent").
241
+ -- One refusal became a permanent, self-unrecoverable bar.
242
+ --
243
+ -- Applied to BOTH pending origins, not just `requested`. Cancelling an INVITATION had the
244
+ -- identical trap, and fixing only the named instance is how several defects in this work
245
+ -- survived a round.
246
+ --
247
+ -- `left` is recoverable: the subject may act again. The refusal is still durable in the
248
+ -- ledger -- this changes the membership STATE, not the record that it happened.
249
+ v_effective_state := case when p_membership_state = 'removed' then 'left'
250
+ else p_membership_state end;
251
+
252
+ -- #82: PROMOTION INTO AN ACTIVE STATE REQUIRES THE SUBJECT'S OWN ACT. This is the sole
253
+ -- consent-predicate read site. An owner resolving a pending row to joined|subscribed is a
254
+ -- BOARD act; it admits the subject only if the subject has already consented -- by requesting
255
+ -- (target-authored) or by an operator placement. A board-authored `invited` or a manufactured
256
+ -- `requested` is NEUTRAL on the ledger and does not admit anyone.
257
+ --
258
+ -- Authenticated only. service_role keeps the operator carve-out.
259
+ if v_actor is not null
260
+ and v_effective_state in ('joined', 'subscribed')
261
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
262
+ raise exception 'admitting % requires an act by that agent', p_agent_id
263
+ using errcode = '42501';
264
+ end if;
265
+
266
+ v_membership := agent_board._upsert_board_membership_state(
267
+ p_board_id,
268
+ p_agent_id,
269
+ v_effective_state,
270
+ p_created_by_agent_id,
271
+ p_metadata
272
+ );
273
+
274
+ -- NOT relabelled here, deliberately. An earlier revision UPDATEd the freshly-emitted
275
+ -- ledger row to `reject_withdrawn`, so the history would say a REFUSAL rather than an
276
+ -- eviction. It worked -- and that is the problem. board_membership_events is append-only by
277
+ -- GRANT: service_role holds SELECT and INSERT and no UPDATE. The write succeeded only
278
+ -- because this function is SECURITY DEFINER and runs as the owner, bypassing grants
279
+ -- entirely. Fixing a descriptive label by punching through the property that makes the
280
+ -- ledger trustworthy is a worse trade than the label being wrong.
281
+ --
282
+ -- The row must instead be written with the right kind the FIRST time, which means threading
283
+ -- a kind through the shared sink. That changes a signature every membership route calls, so
284
+ -- it belongs in the gating change that is already modifying the sink -- reviewed in context,
285
+ -- not bolted onto this one. Filed; until then a refusal records as `eviction`. The membership
286
+ -- STATE is correct and recoverable, which is what #89 is about; only the label is wrong.
287
+
288
+
289
+ return query
290
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
291
+ end;
292
+ $$;
293
+
294
+ create or replace function agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
295
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
296
+ LANGUAGE plpgsql
297
+ SECURITY DEFINER
298
+ SET search_path TO 'agent_board', 'public'
299
+ as $$
300
+ declare
301
+ v_membership agent_board.board_memberships%rowtype;
302
+ v_board agent_board.boards%rowtype;
303
+ v_actor uuid := agent_board.current_agent_id();
304
+ v_is_service boolean := agent_board._is_service_role();
305
+ v_existing_state text;
306
+ v_target_role text;
307
+ v_actor_role text;
308
+ begin
309
+ -- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
310
+ -- cannot be used as a membership-existence oracle.
311
+ if v_actor is null and not v_is_service then
312
+ raise exception 'current agent principal not resolved' using errcode = '42501';
313
+ end if;
314
+
315
+ -- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
316
+ -- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
317
+ -- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
318
+ -- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
319
+ -- own membership, and the service path is the operator.)
320
+ if not v_is_service and v_actor <> p_agent_id then
321
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
322
+ if v_board.id is null then
323
+ raise exception 'board not found' using errcode = 'P0002';
324
+ end if;
325
+ select bm.role
326
+ into v_actor_role
327
+ from agent_board.board_memberships bm
328
+ where bm.board_id = p_board_id
329
+ and bm.agent_id = v_actor
330
+ and bm.membership_state in ('joined', 'subscribed')
331
+ limit 1;
332
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
333
+ raise exception 'board membership change requires owner or admin role'
334
+ using errcode = '42501';
335
+ end if;
336
+ end if;
337
+
338
+ -- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
339
+ -- rank check below.
340
+ -- MBROWID (3a): prefer ACTIVE standing. Bare total-order can surface a high-id REMOVED
341
+ -- row while a low-id JOINED row exists, so self-service would reject a legitimate
342
+ -- joined->joined toggle with 42501 'self membership change not permitted'.
343
+ select bm.membership_state, bm.role
344
+ into v_existing_state, v_target_role
345
+ from agent_board.board_memberships bm
346
+ where bm.board_id = p_board_id
347
+ and bm.agent_id = p_agent_id
348
+ order by
349
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
350
+ bm.created_at desc,
351
+ bm.id desc
352
+ limit 1;
353
+ if v_existing_state is null then
354
+ raise exception 'board membership not found' using errcode = 'P0002';
355
+ end if;
356
+
357
+ -- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
358
+ -- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
359
+ if not v_is_service then
360
+ if v_actor = p_agent_id then
361
+ -- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
362
+ -- for the authority checks below, and requiring an ACTIVE board here would strand an agent
363
+ -- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
364
+ -- delegates straight into this RPC, 20260512000002:154-158).
365
+ --
366
+ -- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
367
+ -- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
368
+ -- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
369
+ -- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
370
+ -- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
371
+ -- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
372
+ --
373
+ -- LEAVEIDEM (#81): a same-state TERMINAL call is additionally permitted. It is a no-op
374
+ -- (returned unchanged at step 5), so it neither erases nor refreshes the marker — it simply
375
+ -- restores idempotence to leave_board without widening the transition set.
376
+ if not (
377
+ (p_membership_state = 'left'
378
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
379
+ or (p_membership_state in ('joined', 'subscribed')
380
+ and v_existing_state in ('joined', 'subscribed'))
381
+ or (p_membership_state = v_existing_state
382
+ and p_membership_state in ('left', 'removed'))
383
+ ) then
384
+ raise exception 'self membership change not permitted; use the join/approval path'
385
+ using errcode = '42501';
386
+ end if;
387
+ else
388
+ -- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
389
+ -- requirement already ran in step (2) (authority before disclosure). This RPC is the
390
+ -- schema's only eviction path — no sibling can move a JOINED member to removed
391
+ -- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
392
+ -- gated more strictly than its siblings, with NO creator exemption: a bare
393
+ -- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
394
+ -- membership at create_board time (20260723000100), so a legitimate creator holds one, while
395
+ -- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
396
+ --
397
+ -- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
398
+ -- could evict the OWNER, or admins could evict each other.
399
+ -- The rank comparison must use the EFFECTIVE POST-TRANSITION role, not the stale retained
400
+ -- one. Roles are preserved on a `left`/`removed` row, and the sink CLAMPS any
401
+ -- non-active -> active admission back to 'member' (MBROLERESET). So a departed OWNER who
402
+ -- genuinely requests re-entry was compared at rank owner <= owner and refused -- while the
403
+ -- role they would actually receive is `member`. That blocked a CONSENTING subject from
404
+ -- being re-admitted, which is this phase's own EC-2, and it is the allow-side failure the
405
+ -- deny side cannot see.
406
+ --
407
+ -- For an admission the effective role is what the clamp will produce; for every other
408
+ -- transition the retained role stands, so eviction and demotion still require strict rank
409
+ -- superiority over the role the target actually holds.
410
+ if agent_board._role_rank(v_actor_role)
411
+ <= agent_board._role_rank(
412
+ case
413
+ when p_membership_state in ('joined', 'subscribed')
414
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
415
+ then 'member'
416
+ else coalesce(v_target_role, '')
417
+ end) then
418
+ raise exception 'board membership change requires a higher board role than the target'
419
+ using errcode = '42501';
420
+ end if;
421
+
422
+ -- #82: an owner or admin may EVICT or demote another agent, but may not ADMIT one.
423
+ -- Moving someone into joined|subscribed is exactly the force-join this phase closes, and
424
+ -- no authority short of the subject's own act permits it. Manufacturing a `requested` row
425
+ -- stays allowed -- it is neutral on the ledger and admits nobody, and the promotion that
426
+ -- would follow is refused at resolve_board_request's predicate read.
427
+ --
428
+ -- service_role retains update-other promotion as an operator placement.
429
+ -- The rule is ADMISSION REQUIRES CONSENT, not "no promotion ever". Three cases:
430
+ --
431
+ -- * already ACTIVE -> active (joined <-> subscribed): NOT an admission. The subject is
432
+ -- already a member and consented to be; changing which active state they hold admits
433
+ -- nobody. Refusing it broke a legitimate owner flow.
434
+ -- * NOT active -> active WITH consent on the ledger: allowed. The subject asked, or an
435
+ -- operator placed them. An owner acting on that is an approval, not a force-join.
436
+ -- * NOT active -> active WITHOUT consent: refused. This is #82.
437
+ --
438
+ -- An earlier revision refused ALL other-agent promotion regardless of consent or origin.
439
+ -- That contradicted this phase's own acceptance criterion (an owner may promote a
440
+ -- CONSENTING subject) and over-blocked active->active. Deny-side correctness is worthless
441
+ -- if it takes the allow side with it.
442
+ if p_membership_state in ('joined', 'subscribed')
443
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
444
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
445
+ raise exception 'admitting % requires an act by that agent', p_agent_id
446
+ using errcode = '42501';
447
+ end if;
448
+ end if;
449
+ end if;
450
+
451
+ -- (5) LEAVEIDEM (#81): a same-state TERMINAL call is a TRUE no-op — return the row untouched
452
+ -- rather than re-entering the mutator, which would restamp left_at and re-merge metadata.
453
+ -- Placed AFTER the authorization block on purpose: returning early before step (2)/(4) would
454
+ -- hand an UNAUTHORIZED caller a successful no-op instead of 42501, turning this RPC into a
455
+ -- membership-state oracle for arbitrary (board, agent) pairs. Authority is still required; the
456
+ -- entitled caller simply gets an unchanged row.
457
+ -- Scoped to the AUTHENTICATED path on purpose: a service_role/operator caller re-asserting a
458
+ -- terminal state may legitimately be attaching an annotation via p_metadata (CLI --metadata), and
459
+ -- silently dropping it would be a lossy success. The operator falls through to the mutator, which
460
+ -- now preserves left_at for an UNCHANGED terminal state anyway — so the operator keeps the
461
+ -- metadata merge without reacquiring the restamp defect.
462
+ if not v_is_service
463
+ and p_membership_state = v_existing_state
464
+ and p_membership_state in ('left', 'removed') then
465
+ -- MBROWID (3a): same active-preferring total-order as the authz pre-read above so
466
+ -- the no-op return row agrees with the state used for authorization.
467
+ return query
468
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
469
+ from agent_board.board_memberships bm
470
+ where bm.board_id = p_board_id
471
+ and bm.agent_id = p_agent_id
472
+ order by
473
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
474
+ bm.created_at desc,
475
+ bm.id desc
476
+ limit 1;
477
+ return;
478
+ end if;
479
+
480
+ select * into v_membership
481
+ from agent_board._upsert_board_membership_state(
482
+ p_board_id,
483
+ p_agent_id,
484
+ p_membership_state,
485
+ null,
486
+ p_metadata
487
+ );
488
+
489
+ return query
490
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
491
+ end;
492
+ $$;