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