@consiliency/agent-board-schema 1.10.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,317 @@
1
+ -- LEAVEIDEM — terminal membership states are TRUE no-ops, and departure time is not
2
+ -- caller-controlled. Closes message-board#81.
3
+ --
4
+ -- THEME (one invariant, two functions): re-asserting a terminal membership state must not mutate
5
+ -- the row, and `left_at` / the reserved `ops_*` metadata namespace must not be writable by the
6
+ -- agent whose retention they govern.
7
+ --
8
+ -- DEFECT 1 — leave_board is no longer idempotent (a regression introduced by MEMBSTATE, #77).
9
+ -- `leave_board` (20260512000002:154-158) delegates into update_board_membership(..., 'left').
10
+ -- MEMBSTATE's self-branch permits 'left' only FROM joined|subscribed|invited|requested, so a
11
+ -- SECOND leave_board raises 42501 'self membership change not permitted'. Proven live. A naturally
12
+ -- idempotent operation now hard-fails, and defensive client retries surface an authorization error.
13
+ -- The 'removed' exclusion in that branch was correct and is PRESERVED — a kicked agent still may
14
+ -- not erase its own kick marker (#79). Only the same-state terminal case is opened.
15
+ --
16
+ -- DEFECT 2 — `left_at` is caller-restampable, and it is the retention sweep's cutoff key.
17
+ -- `_upsert_board_membership_state` unconditionally sets
18
+ -- left_at = case when p_membership_state in ('left','removed') then now() else null end
19
+ -- on EVERY call, so any repeat call into a terminal state moves the departure timestamp forward.
20
+ -- `mark_retention_metadata` sweeps board_memberships on `left_at < cutoff`
21
+ -- (20260513000001:115-121), so an agent able to restamp its own `left_at` can postpone its own
22
+ -- row's retention indefinitely. This is NOT limited to the double-leave path: an owner re-kicking
23
+ -- an already-`removed` member resets that row's clock too. Fixed at the mutator so every caller
24
+ -- benefits: the FIRST departure time is preserved via coalesce(bm.left_at, now()).
25
+ -- (Transitions back to a non-terminal state still NULL `left_at` — required by the
26
+ -- board_memberships state/left_at CHECK, 20260515000000:18-25 — and that is correct: a
27
+ -- re-activated membership has no departure time.)
28
+ --
29
+ -- DEFECT 3 — the reserved `ops_*` metadata namespace is caller-writable.
30
+ -- The mutator merges caller-supplied p_metadata verbatim on every path. The retention sweep SKIPS
31
+ -- rows whose `metadata->>'ops_retained_at'` is non-empty (20260513000001:122). So any caller that
32
+ -- can reach the mutator — join_board, request_join_board, invite_board_member,
33
+ -- resolve_board_request, update_board_membership/leave_board — can write that key and exclude the
34
+ -- row from retention permanently. Note this is reachable on a FIRST leave, so the no-op fix alone
35
+ -- would not close it. Caller-supplied keys in the reserved `ops_` namespace are now stripped
36
+ -- before the merge; operational writers set them directly (mark_retention_metadata writes the
37
+ -- column, it does not go through this mutator).
38
+ --
39
+ -- AUTHZ PLACEMENT (deliberate divergence from the review's suggestion). The panel proposed the
40
+ -- early return sit between the existence check and the authorization block. That would hand an
41
+ -- UNAUTHORIZED caller a successful no-op instead of 42501, turning the RPC into a membership-state
42
+ -- oracle for arbitrary (board, agent) pairs. Instead the self-branch permits the same-state
43
+ -- terminal call, and the no-op return happens AFTER the authorization block — so authority is
44
+ -- still required, the 42501 posture for unauthorized callers is unchanged, and the caller who is
45
+ -- entitled to act simply gets an unchanged row back.
46
+ --
47
+ -- Additive, forward-only. No signature change (so no PostgREST reload and no client/mcp/cli
48
+ -- surface change); leave_board inherits the fix automatically through its delegation. Writes no
49
+ -- agent_board.schema_version (stays 8).
50
+
51
+ -- 1. Mutator: preserve the first departure time; strip the reserved ops_ namespace. -------------
52
+ create or replace function agent_board._upsert_board_membership_state(
53
+ p_board_id uuid,
54
+ p_agent_id uuid,
55
+ p_membership_state text,
56
+ p_created_by_agent_id uuid default null,
57
+ p_metadata jsonb default '{}'::jsonb
58
+ )
59
+ returns agent_board.board_memberships
60
+ language plpgsql
61
+ security definer
62
+ set search_path = agent_board, public
63
+ as $$
64
+ declare
65
+ v_membership agent_board.board_memberships%rowtype;
66
+ v_metadata jsonb;
67
+ begin
68
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
69
+ raise exception 'invalid board membership state' using errcode = '22023';
70
+ end if;
71
+
72
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
73
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
74
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
75
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
76
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
77
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
78
+ end if;
79
+
80
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
81
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
82
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
83
+ -- the column directly.
84
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
85
+ into v_metadata
86
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
87
+ where e.key not like 'ops\_%';
88
+
89
+ select *
90
+ into v_membership
91
+ from agent_board.board_memberships bm
92
+ where bm.board_id = p_board_id
93
+ and bm.agent_id = p_agent_id
94
+ order by bm.created_at desc
95
+ limit 1
96
+ for update;
97
+
98
+ if v_membership.id is null then
99
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
100
+ values (
101
+ p_board_id,
102
+ p_agent_id,
103
+ p_membership_state,
104
+ p_created_by_agent_id,
105
+ case when p_membership_state in ('left', 'removed') then now() else null end,
106
+ v_metadata
107
+ )
108
+ returning * into v_membership;
109
+ else
110
+ update agent_board.board_memberships bm
111
+ set membership_state = p_membership_state,
112
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
113
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
114
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
115
+ left_at = case
116
+ when p_membership_state in ('left', 'removed') then
117
+ case
118
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
119
+ -- repeat call cannot push the retention cutoff forward.
120
+ when v_membership.membership_state = p_membership_state
121
+ then coalesce(v_membership.left_at, now())
122
+ -- GENUINE transition (notably left -> removed, an owner barring
123
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
124
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
125
+ -- so this is the real kick moment; inheriting the earlier departure would
126
+ -- both lose the audit answer and make the marker row retention-eligible
127
+ -- prematurely.
128
+ else now()
129
+ end
130
+ else null
131
+ end,
132
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
133
+ updated_at = now()
134
+ where bm.id = v_membership.id
135
+ returning * into v_membership;
136
+ end if;
137
+
138
+ return v_membership;
139
+ end;
140
+ $$;
141
+
142
+ comment on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb) is
143
+ 'UNAUTHORIZED PRIMITIVE — accepts any membership state and never writes `role`; every caller '
144
+ 'MUST gate. LEAVEIDEM (#81): preserves the FIRST left_at across repeated terminal transitions '
145
+ '(the retention sweep keys on left_at, 20260513000001:121) and strips the reserved `ops_*` '
146
+ 'metadata namespace from caller input (the sweep skips rows carrying ops_retained_at).';
147
+
148
+ -- 2. update_board_membership: same-state terminal calls are true no-ops, AFTER authorization. ---
149
+ create or replace function agent_board.update_board_membership(
150
+ p_board_id uuid,
151
+ p_agent_id uuid,
152
+ p_membership_state text,
153
+ p_metadata jsonb default '{}'::jsonb
154
+ )
155
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
156
+ language plpgsql
157
+ security definer
158
+ set search_path = agent_board, public
159
+ as $$
160
+ declare
161
+ v_membership agent_board.board_memberships%rowtype;
162
+ v_board agent_board.boards%rowtype;
163
+ v_actor uuid := agent_board.current_agent_id();
164
+ v_is_service boolean := agent_board._is_service_role();
165
+ v_existing_state text;
166
+ v_target_role text;
167
+ v_actor_role text;
168
+ begin
169
+ -- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
170
+ -- cannot be used as a membership-existence oracle.
171
+ if v_actor is null and not v_is_service then
172
+ raise exception 'current agent principal not resolved' using errcode = '42501';
173
+ end if;
174
+
175
+ -- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
176
+ -- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
177
+ -- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
178
+ -- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
179
+ -- own membership, and the service path is the operator.)
180
+ if not v_is_service and v_actor <> p_agent_id then
181
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
182
+ if v_board.id is null then
183
+ raise exception 'board not found' using errcode = 'P0002';
184
+ end if;
185
+ select bm.role
186
+ into v_actor_role
187
+ from agent_board.board_memberships bm
188
+ where bm.board_id = p_board_id
189
+ and bm.agent_id = v_actor
190
+ and bm.membership_state in ('joined', 'subscribed')
191
+ limit 1;
192
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
193
+ raise exception 'board membership change requires owner or admin role'
194
+ using errcode = '42501';
195
+ end if;
196
+ end if;
197
+
198
+ -- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
199
+ -- rank check below.
200
+ select bm.membership_state, bm.role
201
+ into v_existing_state, v_target_role
202
+ from agent_board.board_memberships bm
203
+ where bm.board_id = p_board_id
204
+ and bm.agent_id = p_agent_id
205
+ order by bm.created_at desc
206
+ limit 1;
207
+ if v_existing_state is null then
208
+ raise exception 'board membership not found' using errcode = 'P0002';
209
+ end if;
210
+
211
+ -- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
212
+ -- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
213
+ if not v_is_service then
214
+ if v_actor = p_agent_id then
215
+ -- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
216
+ -- for the authority checks below, and requiring an ACTIVE board here would strand an agent
217
+ -- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
218
+ -- delegates straight into this RPC, 20260512000002:154-158).
219
+ --
220
+ -- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
221
+ -- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
222
+ -- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
223
+ -- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
224
+ -- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
225
+ -- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
226
+ --
227
+ -- LEAVEIDEM (#81): a same-state TERMINAL call is additionally permitted. It is a no-op
228
+ -- (returned unchanged at step 5), so it neither erases nor refreshes the marker — it simply
229
+ -- restores idempotence to leave_board without widening the transition set.
230
+ if not (
231
+ (p_membership_state = 'left'
232
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
233
+ or (p_membership_state in ('joined', 'subscribed')
234
+ and v_existing_state in ('joined', 'subscribed'))
235
+ or (p_membership_state = v_existing_state
236
+ and p_membership_state in ('left', 'removed'))
237
+ ) then
238
+ raise exception 'self membership change not permitted; use the join/approval path'
239
+ using errcode = '42501';
240
+ end if;
241
+ else
242
+ -- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
243
+ -- requirement already ran in step (2) (authority before disclosure). This RPC is the
244
+ -- schema's only eviction path — no sibling can move a JOINED member to removed
245
+ -- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
246
+ -- gated more strictly than its siblings, with NO creator exemption: a bare
247
+ -- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
248
+ -- membership at create_board time (20260723000100), so a legitimate creator holds one, while
249
+ -- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
250
+ --
251
+ -- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
252
+ -- could evict the OWNER, or admins could evict each other.
253
+ if agent_board._role_rank(v_actor_role)
254
+ <= agent_board._role_rank(coalesce(v_target_role, '')) then
255
+ raise exception 'board membership change requires a higher board role than the target'
256
+ using errcode = '42501';
257
+ end if;
258
+ end if;
259
+ end if;
260
+
261
+ -- (5) LEAVEIDEM (#81): a same-state TERMINAL call is a TRUE no-op — return the row untouched
262
+ -- rather than re-entering the mutator, which would restamp left_at and re-merge metadata.
263
+ -- Placed AFTER the authorization block on purpose: returning early before step (2)/(4) would
264
+ -- hand an UNAUTHORIZED caller a successful no-op instead of 42501, turning this RPC into a
265
+ -- membership-state oracle for arbitrary (board, agent) pairs. Authority is still required; the
266
+ -- entitled caller simply gets an unchanged row.
267
+ -- Scoped to the AUTHENTICATED path on purpose: a service_role/operator caller re-asserting a
268
+ -- terminal state may legitimately be attaching an annotation via p_metadata (CLI --metadata), and
269
+ -- silently dropping it would be a lossy success. The operator falls through to the mutator, which
270
+ -- now preserves left_at for an UNCHANGED terminal state anyway — so the operator keeps the
271
+ -- metadata merge without reacquiring the restamp defect.
272
+ if not v_is_service
273
+ and p_membership_state = v_existing_state
274
+ and p_membership_state in ('left', 'removed') then
275
+ return query
276
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
277
+ from agent_board.board_memberships bm
278
+ where bm.board_id = p_board_id
279
+ and bm.agent_id = p_agent_id
280
+ order by bm.created_at desc
281
+ limit 1;
282
+ return;
283
+ end if;
284
+
285
+ select * into v_membership
286
+ from agent_board._upsert_board_membership_state(
287
+ p_board_id,
288
+ p_agent_id,
289
+ p_membership_state,
290
+ null,
291
+ p_metadata
292
+ );
293
+
294
+ return query
295
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
296
+ end;
297
+ $$;
298
+
299
+ comment on function agent_board.update_board_membership(uuid, uuid, text, jsonb) is
300
+ 'Updates a board membership state. MEMBSTATE (#75): service_role retains full '
301
+ 'modify-on-behalf-of; an authenticated caller with an unresolved actor fails closed (42501); a '
302
+ 'caller acting on its OWN membership may only leave or toggle joined<->subscribed (promotion '
303
+ 'into a board belongs to the join/approval path, so a requested/invited/left/removed principal '
304
+ 'cannot self-approve or re-enter); a caller acting on ANOTHER agent''s membership must hold an '
305
+ 'active owner/admin membership AND strictly higher board rank than the target. LEAVEIDEM (#81): '
306
+ 'a same-state terminal call (left->left, removed->removed) is a TRUE no-op returned unchanged '
307
+ 'AFTER authorization — leave_board is idempotent again and left_at is never restamped.';
308
+
309
+ -- Re-assert grants (create or replace preserves them, but be explicit so a fresh replay is
310
+ -- order-independent) — house convention, 20260725000100:229-232.
311
+ revoke all on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
312
+ from public, anon, authenticated;
313
+ grant execute on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
314
+ to service_role;
315
+ revoke all on function agent_board.update_board_membership(uuid, uuid, text, jsonb) from public, anon;
316
+ grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb)
317
+ to authenticated, service_role;
@@ -0,0 +1,237 @@
1
+ -- MBROLERESET — role is not identity across membership re-entry. Closes message-board#79.
2
+ --
3
+ -- THEME: an elevated board role must not survive a non-active -> active transition. Today
4
+ -- `_upsert_board_membership_state` never writes `role` (INSERT omits it; UPDATE omits it), so
5
+ -- a removed/left/requested/invited row that re-enters as joined|subscribed keeps whatever role
6
+ -- was last written — including a prior admin/owner, and including grants applied while the row
7
+ -- was still non-active (the re-arm half of #79). Separately, `grant_board_role` matches any
8
+ -- membership row by board_id+agent_id alone, so a grant onto a removed row succeeds and parks
9
+ -- the elevated role for the next re-entry.
10
+ --
11
+ -- CHANGE 1 — re-entry role clamp in `_upsert_board_membership_state` (UPDATE branch only).
12
+ -- When the target state is joined|subscribed AND the pre-read origin is NOT already
13
+ -- joined|subscribed, set role = 'member' (the table default at 20260717000100:13-15). Hardcoded
14
+ -- — do not read information_schema.columns.column_default at runtime (it returns the quoted
15
+ -- expression '''member''::text', not the value, and assigning it trips the CHECK). The INSERT
16
+ -- branch is intentionally untouched: no-prior-row entry already omits role and takes the table
17
+ -- default. Active -> active (joined <-> subscribed) preserves role, including owner.
18
+ --
19
+ -- This clamp is NECESSARY but NOT a chokepoint. Four raw-INSERT writers bypass
20
+ -- `_upsert_board_membership_state` entirely and are deferred to a follow-up phase (MBROWID):
21
+ -- * create_board owner seed (20260723000100:103-106)
22
+ -- * bootstrap_board_owner_memberships (20260723000100:149-152)
23
+ -- * _redeem_invite_for_agent (20260724000100:345-360)
24
+ -- * _fedsession_autojoin_default_board (20260729000100:319-330)
25
+ -- Those paths intentionally write non-default roles (owner / preset admin). A trigger on
26
+ -- board_memberships is FORBIDDEN for the same reason: a BEFORE INSERT/UPDATE trigger would
27
+ -- also fire for those four seeders and erase their intentional roles. The clamp lives in the
28
+ -- mutator only.
29
+ --
30
+ -- No service_role exemption. An operator re-activating an evicted agent gets the default role;
31
+ -- elevation is an explicit subsequent grant_board_role call.
32
+ --
33
+ -- CHANGE 2 — active-membership filter in `grant_board_role` (pre-check AND UPDATE).
34
+ -- Both places gain `and bm.membership_state in ('joined','subscribed')`. Pre-check alone leaves
35
+ -- a TOCTOU window (eviction between check and UPDATE); UPDATE alone leaves v_updated NULL so
36
+ -- RETURN QUERY emits a bogus all-null row. Error message and errcode stay exactly
37
+ -- 'grantee has no membership on this board' / P0002. Grantor binding
38
+ -- (_resolved_actor_agent_id) and _grant_within_ceiling are unchanged — no privilege-escalation
39
+ -- regression.
40
+ --
41
+ -- SOLE-OWNER LOCKOUT (documented limitation, not a defect of this migration): a sole owner who
42
+ -- self-leaves is clamped to 'member' on rejoin, and grant_board_role CANNOT restore them --
43
+ -- it checks _grant_within_ceiling unconditionally with no service_role bypass, so an operator
44
+ -- must nominate a grantor that already holds rank, and by construction none exists.
45
+ -- Recovery DOES exist and is an RPC: leave again (so the creator holds no ACTIVE membership),
46
+ -- then call bootstrap_board_owner_memberships(), which raw-inserts a joined/owner row for a
47
+ -- creator lacking one (20260723000100:138-166). Exercised end-to-end by assertion 23 of
48
+ -- tests/agent_board_rolereset.test.sql. An earlier draft of this header claimed there was NO
49
+ -- RPC recovery; that was refuted during review and is corrected here rather than shipped into
50
+ -- immutable released bytes.
51
+ --
52
+ -- Additive, forward-only. No signature change. Last-definer rule: bodies are copied forward from
53
+ -- 20260801000100 (_upsert) and 20260723000200 (grant_board_role) and modified in place.
54
+
55
+ -- 1. _upsert_board_membership_state: re-entry role clamp on the UPDATE branch only ------------
56
+ create or replace function agent_board._upsert_board_membership_state(
57
+ p_board_id uuid,
58
+ p_agent_id uuid,
59
+ p_membership_state text,
60
+ p_created_by_agent_id uuid default null,
61
+ p_metadata jsonb default '{}'::jsonb
62
+ )
63
+ returns agent_board.board_memberships
64
+ language plpgsql
65
+ security definer
66
+ set search_path = agent_board, public
67
+ as $$
68
+ declare
69
+ v_membership agent_board.board_memberships%rowtype;
70
+ v_metadata jsonb;
71
+ begin
72
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
73
+ raise exception 'invalid board membership state' using errcode = '22023';
74
+ end if;
75
+
76
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
77
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
78
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
79
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
80
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
81
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
82
+ end if;
83
+
84
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
85
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
86
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
87
+ -- the column directly.
88
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
89
+ into v_metadata
90
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
91
+ where e.key not like 'ops\_%';
92
+
93
+ select *
94
+ into v_membership
95
+ from agent_board.board_memberships bm
96
+ where bm.board_id = p_board_id
97
+ and bm.agent_id = p_agent_id
98
+ order by bm.created_at desc
99
+ limit 1
100
+ for update;
101
+
102
+ if v_membership.id is null then
103
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
104
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
105
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
106
+ values (
107
+ p_board_id,
108
+ p_agent_id,
109
+ p_membership_state,
110
+ p_created_by_agent_id,
111
+ case when p_membership_state in ('left', 'removed') then now() else null end,
112
+ v_metadata
113
+ )
114
+ returning * into v_membership;
115
+ else
116
+ update agent_board.board_memberships bm
117
+ set membership_state = p_membership_state,
118
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
119
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
120
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
121
+ left_at = case
122
+ when p_membership_state in ('left', 'removed') then
123
+ case
124
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
125
+ -- repeat call cannot push the retention cutoff forward.
126
+ when v_membership.membership_state = p_membership_state
127
+ then coalesce(v_membership.left_at, now())
128
+ -- GENUINE transition (notably left -> removed, an owner barring
129
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
130
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
131
+ -- so this is the real kick moment; inheriting the earlier departure would
132
+ -- both lose the audit answer and make the marker row retention-eligible
133
+ -- prematurely.
134
+ else now()
135
+ end
136
+ else null
137
+ end,
138
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
139
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
140
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
141
+ -- Unconditional — no service_role exemption.
142
+ role = case
143
+ when p_membership_state in ('joined', 'subscribed')
144
+ and v_membership.membership_state not in ('joined', 'subscribed')
145
+ then 'member'
146
+ else bm.role
147
+ end,
148
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
149
+ updated_at = now()
150
+ where bm.id = v_membership.id
151
+ returning * into v_membership;
152
+ end if;
153
+
154
+ return v_membership;
155
+ end;
156
+ $$;
157
+
158
+ comment on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb) is
159
+ 'UNAUTHORIZED PRIMITIVE — accepts any membership state; every caller MUST gate. ROLERESET '
160
+ '(#79): on the UPDATE path only, clamps role to the table default (''member'', '
161
+ '20260717000100:13-15) when transitioning from a non-active origin into joined|subscribed; '
162
+ 'INSERT omits role and takes the table default. Active->active preserves role. LEAVEIDEM '
163
+ '(#81): preserves the FIRST left_at across repeated terminal transitions (the retention sweep '
164
+ 'keys on left_at, 20260513000001:121) and strips the reserved `ops_*` metadata namespace from '
165
+ 'caller input (the sweep skips rows carrying ops_retained_at). Not a chokepoint — raw-INSERT '
166
+ 'seeders (create_board owner, bootstrap, invite redeem, fedsession autojoin) bypass this '
167
+ 'function and are out of scope (MBROWID).';
168
+
169
+ -- 2. grant_board_role: refuse grants onto non-active memberships (pre-check + UPDATE) ---------
170
+ create or replace function agent_board.grant_board_role(
171
+ p_board_id uuid,
172
+ p_agent_id uuid,
173
+ p_grantee_role text,
174
+ p_grantor_agent_id uuid default null
175
+ )
176
+ returns table(board_id uuid, agent_id uuid, role text, updated_at timestamptz)
177
+ language plpgsql
178
+ security definer
179
+ set search_path = agent_board, public
180
+ as $$
181
+ declare
182
+ v_grantor uuid;
183
+ v_updated agent_board.board_memberships%rowtype;
184
+ begin
185
+ if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
186
+ raise exception 'invalid grantee role' using errcode = '22023';
187
+ end if;
188
+
189
+ -- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
190
+ -- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
191
+ -- supplied arg <> current_agent_id(). This closes the single-principal escalation.
192
+ v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
193
+
194
+ if not exists (
195
+ select 1 from agent_board.board_memberships bm
196
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id
197
+ and bm.membership_state in ('joined', 'subscribed')
198
+ ) then
199
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
200
+ end if;
201
+
202
+ -- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
203
+ if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
204
+ raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
205
+ end if;
206
+
207
+ update agent_board.board_memberships bm
208
+ set role = p_grantee_role,
209
+ updated_at = now()
210
+ where bm.board_id = p_board_id
211
+ and bm.agent_id = p_agent_id
212
+ and bm.membership_state in ('joined', 'subscribed')
213
+ returning * into v_updated;
214
+
215
+ -- CR finding: the state predicate above closes the unauthorized WRITE, but on its own it
216
+ -- CREATES a false-success path. If an eviction commits between the pre-check and this
217
+ -- UPDATE (the TOCTOU window the mirrored predicate exists for), the UPDATE matches zero
218
+ -- rows, v_updated stays NULL, and the RETURN QUERY below would emit ONE ALL-NULL ROW with
219
+ -- no error -- a caller that checks "did I get a row back?" reads that as a completed grant.
220
+ -- Fail closed with the same P0002 the pre-check raises, so the serial and racing cases are
221
+ -- indistinguishable to the caller.
222
+ if not found then
223
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
224
+ end if;
225
+
226
+ return query
227
+ select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
228
+ end;
229
+ $$;
230
+
231
+ comment on function agent_board.grant_board_role(uuid, uuid, text, uuid) is
232
+ 'Live board-role grant chokepoint. Grantor is the resolved actor (_resolved_actor_agent_id: '
233
+ 'service_role uses the supplied arg, authenticated uses current_agent_id() and fails closed on '
234
+ 'null/mismatch). Requires an ACTIVE grantee membership (joined|subscribed; P0002 otherwise) — '
235
+ 'predicate on both the pre-check and the UPDATE (TOCTOU). Then enforces '
236
+ '_grant_within_ceiling(grantor, board, grantee_role) before setting board_memberships.role. '
237
+ 'ROLERESET (#79): non-active rows cannot be re-armed with an elevated role.';