@consiliency/agent-board-schema 1.28.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +28 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/package.json +1 -1
- package/schema-manifest.json +66 -3
- package/src/tooling.test.mjs +47 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_ledger_authority.test.sql +142 -71
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +157 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/test-prereqs.sql +420 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
-- Consiliency/message-board#246 pgTAP: the federated-human return path returns an
|
|
2
|
+
-- existing agent ONLY when that agent's STORED (issuer, subject) equals the pair the
|
|
3
|
+
-- caller presented.
|
|
4
|
+
--
|
|
5
|
+
-- WHY THE COLLISION HERE IS CONSTRUCTED, NOT DERIVED. The owner uid is a truncated
|
|
6
|
+
-- sha256 over a serialization of (issuer, subject). When this file shipped that
|
|
7
|
+
-- serialization was `issuer || '|' || subject`, whose AMBIGUITY was reachable: an OPERATOR
|
|
8
|
+
-- registering a second provider whose issuer carried the complementary suffix produced two
|
|
9
|
+
-- real pairs on one uid. #246 change 2 (20260916000100) replaced it with a length-prefixed
|
|
10
|
+
-- form and that route is gone -- so today reaching one uid from two pairs would be a
|
|
11
|
+
-- preimage-class result against SHA-256, which is not something a test can do. EITHER WAY
|
|
12
|
+
-- this file's gate is the property under test and it does not depend on which
|
|
13
|
+
-- serialization is in force, which is the point: the state is built directly instead: enroll a real
|
|
14
|
+
-- federated human, then REWRITE the (issuer, subject) stored in its
|
|
15
|
+
-- metadata->'federation' stamp. The uid, the handle and the auth.users row are
|
|
16
|
+
-- untouched and still derive from the caller's pair.
|
|
17
|
+
--
|
|
18
|
+
-- That is sound for THIS branch, and the reason is narrow enough to state exactly.
|
|
19
|
+
-- The code under test reads two things and nothing else: the agent found on the
|
|
20
|
+
-- derived uid, and the caller's p_issuer/p_subject. It never re-derives, never
|
|
21
|
+
-- re-hashes, and never consults how the row came to be on that uid. At the point of
|
|
22
|
+
-- the check, a constructed stamp mismatch and a real hash collision are the SAME
|
|
23
|
+
-- STATE. What this file therefore does NOT prove is that a collision is reachable --
|
|
24
|
+
-- that is the encoding question, which is a separate change (#246 change 2) with its
|
|
25
|
+
-- own migration and Portal coordination. This file proves that IF two principals ever
|
|
26
|
+
-- land on one uid, the second is refused.
|
|
27
|
+
--
|
|
28
|
+
-- WHAT IS PINNED STRUCTURALLY RATHER THAN BEHAVIOURALLY, said plainly. There are TWO
|
|
29
|
+
-- return sites: step (7)'s idempotent branch and step (8)'s `exception when
|
|
30
|
+
-- unique_violation` race handler. Only (7) is reachable from a single session -- (7)'s
|
|
31
|
+
-- select is unfiltered on the uid, so any already-committed row on that uid is
|
|
32
|
+
-- intercepted there and the insert in (8) is never attempted. Reaching (8) requires a
|
|
33
|
+
-- concurrent transaction to commit the agent inside the window between (7)'s select
|
|
34
|
+
-- and (8)'s insert. So (8a) is pinned by assertion 5 below, which locates the gate
|
|
35
|
+
-- INSIDE the handler positionally -- not by a count, which cannot tell "both sites
|
|
36
|
+
-- gated" from "one site gated twice" (see the note above assertion 3). A differential
|
|
37
|
+
-- behavioural test of (8a) is a two-session isolationtester follow-up, not something
|
|
38
|
+
-- this file claims.
|
|
39
|
+
--
|
|
40
|
+
-- Runs as postgres (service_role-equivalent), mirroring agent_board_fedsession.test.sql.
|
|
41
|
+
|
|
42
|
+
create extension if not exists pgtap with schema extensions;
|
|
43
|
+
set search_path = extensions, public, agent_board;
|
|
44
|
+
|
|
45
|
+
begin;
|
|
46
|
+
|
|
47
|
+
select plan(29);
|
|
48
|
+
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
-- Section A -- the CLASS, derived from pg_proc, not from a list written here
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
-- Class C: an agent_board function that BOTH derives a `fed-human-` owner handle
|
|
53
|
+
-- AND reads agent_board.agents keyed on auth_user_id -- i.e. a function that can
|
|
54
|
+
-- hand back a row found only by the ambiguous derivation. Membership is computed
|
|
55
|
+
-- from the catalog on every run, so a NEW function of this shape fails EC-1/EC-2
|
|
56
|
+
-- and NAMES ITSELF rather than being silently outside a hand-written list.
|
|
57
|
+
--
|
|
58
|
+
-- Read through agent_board_test.stripped_body(oid, true) (#196/#199): comments and
|
|
59
|
+
-- quoted identifiers are removed in one pass so this file's own prose -- which
|
|
60
|
+
-- contains the words `fed-human-` and `auth_user_id` -- cannot satisfy it. Literals
|
|
61
|
+
-- are KEPT deliberately, and that is the correct mode here rather than a default:
|
|
62
|
+
-- every marker below IS a literal (the derivation call, the `'{federation,issuer}'`
|
|
63
|
+
-- jsonb path, the raise message, the delegation call names), so stripping literals
|
|
64
|
+
-- would make every assertion in sections A and A2 vacuous.
|
|
65
|
+
--
|
|
66
|
+
-- THE CLASS MARKER MOVED IN #246 (change 2 of 3), and the class did not. When this file
|
|
67
|
+
-- shipped, "derives a fed-human owner uid" was detectable as the `fed-human-` prefix
|
|
68
|
+
-- literal, because every site carried its own copy of the derivation. The derivation is
|
|
69
|
+
-- now the single function agent_board._federated_identity_slug, so that literal survives
|
|
70
|
+
-- in exactly one place and would make this scan find NOTHING -- passing every
|
|
71
|
+
-- "every member has X" assertion vacuously, which is the failure mode assertion 1
|
|
72
|
+
-- exists to stop. The marker is therefore the CALL, and membership is byte-for-byte the
|
|
73
|
+
-- same set it was before: the same five derivation sites, the same one class member.
|
|
74
|
+
create temp view _c246 as
|
|
75
|
+
select p.oid, p.proname, agent_board_test.stripped_body(p.oid, true) as body
|
|
76
|
+
from pg_proc p
|
|
77
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
78
|
+
where n.nspname = 'agent_board'
|
|
79
|
+
and p.prokind in ('f', 'p')
|
|
80
|
+
and agent_board_test.stripped_body(p.oid, true) like '%\_federated\_identity\_slug(%'
|
|
81
|
+
and agent_board_test.stripped_body(p.oid, true) ~ 'agent_board\.agents[^;]*auth_user_id\s*=';
|
|
82
|
+
|
|
83
|
+
-- 1 -- the class is NOT EMPTY. A class scan that finds nothing passes every
|
|
84
|
+
-- "every member has X" assertion vacuously; this is the assertion that stops that.
|
|
85
|
+
select is((select count(*)::int from _c246), 1,
|
|
86
|
+
'exactly one agent_board function both derives a fed-human owner uid and reads agents on auth_user_id');
|
|
87
|
+
|
|
88
|
+
-- 2 -- and it is the one we think it is, named POSITIVELY (not "no unexpected members").
|
|
89
|
+
select is((select string_agg(proname, ',' order by proname) from _c246),
|
|
90
|
+
'_enroll_federated_human_after_revocation_gate',
|
|
91
|
+
'that function is _enroll_federated_human_after_revocation_gate');
|
|
92
|
+
|
|
93
|
+
-- BOTH return sites carry the gate -- asserted PER SITE, not as a count.
|
|
94
|
+
--
|
|
95
|
+
-- A count of 2 was the first version of this and it was FAIL-OPEN in a way worth
|
|
96
|
+
-- recording, because it is the shape CLAUDE.md rule 1 warns about: `count = 2` is
|
|
97
|
+
-- equally satisfied by two gates at step (7) and NONE at step (8). The count cannot
|
|
98
|
+
-- tell "both sites gated" from "one site gated twice", so it does not assert the
|
|
99
|
+
-- property whose name it carries. Mutation M7 builds exactly that body and the count
|
|
100
|
+
-- stays green while the two positional assertions below go red.
|
|
101
|
+
--
|
|
102
|
+
-- The body is split on the handler's own `exception when unique_violation`, which is
|
|
103
|
+
-- the syntactic boundary between the two return sites: everything before it is step
|
|
104
|
+
-- (7)'s idempotent branch, everything after it is step (8)'s race handler.
|
|
105
|
+
-- Every assertion below is written as EVERY-MEMBER-vs-ALL-MEMBERS, comparing two
|
|
106
|
+
-- name lists rather than reading a scalar out of the class. That is not style: the
|
|
107
|
+
-- scalar form (`(select pre_handler from _c246parts)`) was the first version, and
|
|
108
|
+
-- mutation M8 -- which adds a SECOND function to the class -- made it raise
|
|
109
|
+
-- `more than one row returned by a subquery`, ABORTING the file after 2 of 27
|
|
110
|
+
-- assertions. The mutation was still caught, by assertions 1 and 2, but 25
|
|
111
|
+
-- assertions died with it, which is precisely the shape CLAUDE.md rule 3 exists to
|
|
112
|
+
-- stop. In this form a new class member reds these assertions and is NAMED in the
|
|
113
|
+
-- `have:` line instead of killing the run.
|
|
114
|
+
create temp view _c246parts as
|
|
115
|
+
select proname,
|
|
116
|
+
(regexp_split_to_array(body, 'exception\s+when\s+unique_violation'))[1] as pre_handler,
|
|
117
|
+
(regexp_split_to_array(body, 'exception\s+when\s+unique_violation'))[2] as in_handler,
|
|
118
|
+
array_length(regexp_split_to_array(body, 'exception\s+when\s+unique_violation'), 1) as parts
|
|
119
|
+
from _c246;
|
|
120
|
+
|
|
121
|
+
-- 3 -- the split boundary is UNAMBIGUOUS for every member. With a second `exception
|
|
122
|
+
-- when unique_violation` the two assertions below would be reading arbitrary halves,
|
|
123
|
+
-- so this is asserted before anything is read from them.
|
|
124
|
+
select is(
|
|
125
|
+
(select string_agg(proname, ',' order by proname) from _c246parts where parts = 2),
|
|
126
|
+
(select string_agg(proname, ',' order by proname) from _c246),
|
|
127
|
+
'every class member has exactly one `exception when unique_violation`, so the per-site split is unambiguous');
|
|
128
|
+
|
|
129
|
+
-- 4 -- SITE (7a): the idempotent branch, everything before the handler.
|
|
130
|
+
select is(
|
|
131
|
+
(select string_agg(proname, ',' order by proname) from _c246parts
|
|
132
|
+
where pre_handler ~ 'coalesce\(\s*v_existing\.metadata\s*#>>\s*''\{federation,issuer\}''\s*=\s*p_issuer\s+and\s+v_existing\.metadata\s*#>>\s*''\{federation,subject\}''\s*=\s*p_subject\s*,\s*false\s*\)'),
|
|
133
|
+
(select string_agg(proname, ',' order by proname) from _c246),
|
|
134
|
+
'every class member carries the gate at return site (7a) -- the idempotent branch, BEFORE the unique_violation handler');
|
|
135
|
+
|
|
136
|
+
-- 5 -- SITE (8a): the race handler, everything after. This is the site a single
|
|
137
|
+
-- session cannot reach (step (7)'s unfiltered select intercepts any committed row),
|
|
138
|
+
-- so this assertion is the ONLY thing standing between it and being ungated.
|
|
139
|
+
select is(
|
|
140
|
+
(select string_agg(proname, ',' order by proname) from _c246parts
|
|
141
|
+
where in_handler ~ 'coalesce\(\s*v_existing\.metadata\s*#>>\s*''\{federation,issuer\}''\s*=\s*p_issuer\s+and\s+v_existing\.metadata\s*#>>\s*''\{federation,subject\}''\s*=\s*p_subject\s*,\s*false\s*\)'),
|
|
142
|
+
(select string_agg(proname, ',' order by proname) from _c246),
|
|
143
|
+
'every class member carries the gate at return site (8a) -- INSIDE the unique_violation race handler');
|
|
144
|
+
|
|
145
|
+
-- 6 and 7 -- both sites raise the SAME refusal, so neither becomes a
|
|
146
|
+
-- distinguishable oracle and one log phrase covers both. Split PER SITE for the
|
|
147
|
+
-- same reason assertions 4 and 5 are: this was written as a body-wide
|
|
148
|
+
-- `count(matches) = 2` and it was the identical fail-open -- M7 (gate twice at (7),
|
|
149
|
+
-- none at (8)) reded assertion 5 and left the count GREEN, so the count could not
|
|
150
|
+
-- see a site that raises nothing. Finding the shape and fixing it in one place
|
|
151
|
+
-- while leaving it in the assertion immediately below is the "does the fix
|
|
152
|
+
-- reproduce one level down" check, and this is that check's answer. A gate is only
|
|
153
|
+
-- half the property: a body could gate both sites and raise a DIFFERENT message at
|
|
154
|
+
-- one of them, which is why the raise is pinned per site rather than inferred from
|
|
155
|
+
-- the gate's position.
|
|
156
|
+
select is(
|
|
157
|
+
(select string_agg(proname, ',' order by proname) from _c246parts
|
|
158
|
+
where pre_handler ~ 'federated identity mismatch'),
|
|
159
|
+
(select string_agg(proname, ',' order by proname) from _c246),
|
|
160
|
+
'every class member raises the "federated identity mismatch" refusal at site (7a) -- the idempotent branch');
|
|
161
|
+
|
|
162
|
+
-- 7
|
|
163
|
+
select is(
|
|
164
|
+
(select string_agg(proname, ',' order by proname) from _c246parts
|
|
165
|
+
where in_handler ~ 'federated identity mismatch'),
|
|
166
|
+
(select string_agg(proname, ',' order by proname) from _c246),
|
|
167
|
+
'every class member raises the "federated identity mismatch" refusal at site (8a) -- INSIDE the race handler');
|
|
168
|
+
|
|
169
|
+
-- ---------------------------------------------------------------------------
|
|
170
|
+
-- Section A2 -- the four NON-members, each exempt for a POSITIVELY ASSERTED reason
|
|
171
|
+
-- ---------------------------------------------------------------------------
|
|
172
|
+
-- The class query above excludes four of the five fed-human derivation sites. An
|
|
173
|
+
-- exemption asserted only by ABSENCE from a scan is the soft spot in every class
|
|
174
|
+
-- test (CLAUDE.md rule 1), so each one's claim is stated positively here and read
|
|
175
|
+
-- from the CATALOG, never from the migration files -- the same reason the fix
|
|
176
|
+
-- targets the delegate and not the wrapper: a migration that introduced a check is
|
|
177
|
+
-- often not the one holding the live one.
|
|
178
|
+
|
|
179
|
+
-- 8 -- the derivation set itself, as an EXACT SET. A new fed-human derivation site
|
|
180
|
+
-- fails this and NAMES ITSELF, which is what makes the four exemptions below a
|
|
181
|
+
-- closed list rather than a list someone remembered to update.
|
|
182
|
+
-- #246: same marker move as the class view above -- a site "derives" by CALLING
|
|
183
|
+
-- agent_board._federated_identity_slug now. The expected set is unchanged, which is the
|
|
184
|
+
-- point: routing six copied expressions through one helper moved no site into or out of
|
|
185
|
+
-- this list. The helper itself is excluded by name because it is the DEFINITION, not a
|
|
186
|
+
-- site; agent_board_federated_identity_injective_slug.test.sql CLS-1/CLS-2 assert from
|
|
187
|
+
-- the other direction that no function outside it holds a derivation of its own.
|
|
188
|
+
select is(
|
|
189
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
190
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
191
|
+
where n.nspname = 'agent_board' and p.prokind in ('f', 'p')
|
|
192
|
+
and p.proname <> '_federated_identity_slug'
|
|
193
|
+
and agent_board_test.stripped_body(p.oid, true) like '%\_federated\_identity\_slug(%'),
|
|
194
|
+
'_enroll_federated_human_after_revocation_gate,_enroll_federated_identity_once,enroll_federated_human,enroll_federated_identity,onboard_federated_fleet_agent',
|
|
195
|
+
'the fed-human derivation set is exactly these five functions');
|
|
196
|
+
|
|
197
|
+
-- 9 -- enroll_federated_identity is exempt because it ALREADY compares the caller's
|
|
198
|
+
-- pair against the creation-time stamp (#176/#244). Asserted on BOTH conjuncts: a
|
|
199
|
+
-- check on the issuer alone would be an exemption whose claim is wider than its code.
|
|
200
|
+
select ok(
|
|
201
|
+
(select agent_board_test.stripped_body(p.oid, true)
|
|
202
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
203
|
+
where n.nspname = 'agent_board' and p.proname = 'enroll_federated_identity')
|
|
204
|
+
~ 'v_creation_federation\s*->>\s*''issuer''\s*=\s*p_issuer'
|
|
205
|
+
and (select agent_board_test.stripped_body(p.oid, true)
|
|
206
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
207
|
+
where n.nspname = 'agent_board' and p.proname = 'enroll_federated_identity')
|
|
208
|
+
~ 'v_creation_federation\s*->>\s*''subject''\s*=\s*p_subject',
|
|
209
|
+
'EXEMPT: enroll_federated_identity already compares BOTH stored issuer and subject to the caller''s pair');
|
|
210
|
+
|
|
211
|
+
-- 10 -- onboard_federated_fleet_agent is exempt because it does not decide identity
|
|
212
|
+
-- itself: it delegates to enroll_federated_identity.
|
|
213
|
+
--
|
|
214
|
+
-- WHAT THIS PINS, exactly: that the CALL APPEARS. Call presence is NOT inheritance
|
|
215
|
+
-- -- a dead call, or one whose result is discarded, would satisfy this identically.
|
|
216
|
+
-- The inheritance argument rests on reading the function (its `select * into
|
|
217
|
+
-- v_enroll from agent_board.enroll_federated_identity(...)` is the sole identity
|
|
218
|
+
-- route, and it raises on that path rather than branching around it), which is a
|
|
219
|
+
-- reviewer's claim and not this assertion's. What the assertion DOES buy is that
|
|
220
|
+
-- the delegation cannot be silently removed without a named failure here, which is
|
|
221
|
+
-- the change that would make the exemption false.
|
|
222
|
+
select ok(
|
|
223
|
+
(select agent_board_test.stripped_body(p.oid, true)
|
|
224
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
225
|
+
where n.nspname = 'agent_board' and p.proname = 'onboard_federated_fleet_agent')
|
|
226
|
+
~ 'agent_board\.enroll_federated_identity\s*\(',
|
|
227
|
+
'EXEMPT: onboard_federated_fleet_agent CALLS agent_board.enroll_federated_identity (call presence only -- see comment)');
|
|
228
|
+
|
|
229
|
+
-- 11 -- _enroll_federated_identity_once is exempt because it has no existing-agent
|
|
230
|
+
-- return path to gate at all: it ALWAYS creates.
|
|
231
|
+
--
|
|
232
|
+
-- WHAT THIS PINS, exactly: that an INSERT into agent_board.agents is present. An
|
|
233
|
+
-- insert proves neither "always" nor "no return path" -- both of those are read off
|
|
234
|
+
-- the function, not measured here. The absence half comes from the CLASS QUERY
|
|
235
|
+
-- (assertions 1-2): this function derives a fed-human handle but does not read
|
|
236
|
+
-- agent_board.agents on auth_user_id, which is why it is not a member. So the
|
|
237
|
+
-- exemption rests on the class query for the absence and on this assertion for the
|
|
238
|
+
-- positive half, and neither is asked to carry the other's weight.
|
|
239
|
+
--
|
|
240
|
+
-- The trailing `\s*\(` is load-bearing, not tidiness. Without it the pattern was
|
|
241
|
+
-- `agent_board\.agents` unanchored, which a mutation renaming the target to
|
|
242
|
+
-- `agent_board.agents_MUTATED` still satisfied by PREFIX -- the assertion stayed
|
|
243
|
+
-- green while the claim it makes was false. Caught by mutation M11 producing no
|
|
244
|
+
-- failure at all, which is the only reason it is not still there.
|
|
245
|
+
select ok(
|
|
246
|
+
(select agent_board_test.stripped_body(p.oid, true)
|
|
247
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
248
|
+
where n.nspname = 'agent_board' and p.proname = '_enroll_federated_identity_once')
|
|
249
|
+
~ 'insert\s+into\s+agent_board\.agents\s*\(',
|
|
250
|
+
'EXEMPT: _enroll_federated_identity_once INSERTS into agent_board.agents (insert presence only -- see comment)');
|
|
251
|
+
|
|
252
|
+
-- 12 -- enroll_federated_human is exempt because 20260901000130 reduced it to a
|
|
253
|
+
-- wrapper: it derives the uid for the owner lock, then delegates. This is the
|
|
254
|
+
-- assertion that would have caught the brief''s misattribution.
|
|
255
|
+
--
|
|
256
|
+
-- `agent_board\.` is OPTIONAL on purpose. The function pins
|
|
257
|
+
-- `search_path = agent_board, public`, so a bare `_enroll_federated_human_after_
|
|
258
|
+
-- revocation_gate(...)` resolves identically; requiring the qualified spelling would
|
|
259
|
+
-- make this red on a legitimate refactor that changed nothing. That direction is a
|
|
260
|
+
-- false alarm rather than a hole, but a class assertion nobody trusts gets deleted.
|
|
261
|
+
select ok(
|
|
262
|
+
(select agent_board_test.stripped_body(p.oid, true)
|
|
263
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
264
|
+
where n.nspname = 'agent_board' and p.proname = 'enroll_federated_human')
|
|
265
|
+
~ '(agent_board\.)?_enroll_federated_human_after_revocation_gate\s*\(',
|
|
266
|
+
'EXEMPT: enroll_federated_human is a wrapper -- it delegates the return path to the gated function');
|
|
267
|
+
|
|
268
|
+
-- ---------------------------------------------------------------------------
|
|
269
|
+
-- Fixtures -- one approved provider, one active default board, seven identities
|
|
270
|
+
-- ---------------------------------------------------------------------------
|
|
271
|
+
create temp table _g (
|
|
272
|
+
iss text, aud text, board_id uuid,
|
|
273
|
+
sub_l text, agent_l uuid, handle_l text, -- L: legitimate owner, stamp untouched
|
|
274
|
+
sub_m text, agent_m uuid, -- M: BOTH stamp fields rewritten
|
|
275
|
+
sub_i text, agent_i uuid, -- I: only the stamp ISSUER rewritten
|
|
276
|
+
sub_s text, agent_s uuid, -- S: only the stamp SUBJECT rewritten
|
|
277
|
+
sub_x text, agent_x uuid, -- X: stamp rewritten AND status suspended
|
|
278
|
+
sub_n text, agent_n uuid, -- N: no federation stamp at all
|
|
279
|
+
sub_p text, agent_p uuid, -- P: federation stamp missing only `subject`
|
|
280
|
+
sub_q text, agent_q uuid -- Q: federation stamp missing only `issuer`
|
|
281
|
+
) on commit drop;
|
|
282
|
+
grant all on table _g to authenticated, service_role;
|
|
283
|
+
|
|
284
|
+
create temp table _err (label text primary key, sqlstate text, msg text) on commit drop;
|
|
285
|
+
grant all on table _err to authenticated, service_role;
|
|
286
|
+
|
|
287
|
+
do $$
|
|
288
|
+
declare
|
|
289
|
+
s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
290
|
+
v_iss text; v_board uuid; v_owner uuid;
|
|
291
|
+
v_l uuid; v_hl text; v_m uuid; v_i uuid; v_ss uuid; v_x uuid; v_n uuid; v_p uuid;
|
|
292
|
+
v_sub_l text; v_sub_m text; v_sub_i text; v_sub_s text; v_sub_x text; v_sub_n text; v_sub_p text;
|
|
293
|
+
v_sub_q text; v_q uuid;
|
|
294
|
+
v_other_iss text := 'https://other-idp-' || s || '.test';
|
|
295
|
+
v_other_sub text := 'other-principal-' || s;
|
|
296
|
+
begin
|
|
297
|
+
v_iss := 'https://fedid-' || s || '.test';
|
|
298
|
+
v_sub_l := 'g246-legit-' || s;
|
|
299
|
+
v_sub_m := 'g246-both-' || s;
|
|
300
|
+
v_sub_i := 'g246-issuer-' || s;
|
|
301
|
+
v_sub_s := 'g246-subject-' || s;
|
|
302
|
+
v_sub_x := 'g246-suspend-' || s;
|
|
303
|
+
v_sub_n := 'g246-nostamp-' || s;
|
|
304
|
+
v_sub_p := 'g246-partial-' || s;
|
|
305
|
+
v_sub_q := 'g246-noiss-' || s;
|
|
306
|
+
|
|
307
|
+
insert into agent_board.agents(agent_type, kind, handle, display_name, status)
|
|
308
|
+
values ('system', 'system'::agent_board.agent_kind, 'g246-owner-' || s, 'G246 Owner', 'active')
|
|
309
|
+
returning id into v_owner;
|
|
310
|
+
|
|
311
|
+
insert into agent_board.boards(slug, name, created_by_agent_id, visibility, status)
|
|
312
|
+
values ('g246-board-' || s, 'G246 Board', v_owner, 'private', 'active')
|
|
313
|
+
returning id into v_board;
|
|
314
|
+
|
|
315
|
+
-- default_board_id is set so the auto-join side effect is LIVE on the return path;
|
|
316
|
+
-- without it EC-13 below would pass no matter what the gate did.
|
|
317
|
+
insert into agent_board.identity_providers
|
|
318
|
+
(issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes,
|
|
319
|
+
allowed_agent_kinds, default_board_id)
|
|
320
|
+
values (v_iss, 'message-board', true, true, '{}', '{}', '{human}', v_board);
|
|
321
|
+
|
|
322
|
+
select e.agent_id, e.handle into v_l, v_hl from agent_board.enroll_federated_human(
|
|
323
|
+
v_iss, 'message-board', v_sub_l,
|
|
324
|
+
jsonb_build_object('sub', v_sub_l, 'email_verified', false), 'Legit') e;
|
|
325
|
+
select e.agent_id into v_m from agent_board.enroll_federated_human(
|
|
326
|
+
v_iss, 'message-board', v_sub_m,
|
|
327
|
+
jsonb_build_object('sub', v_sub_m, 'email_verified', false), 'Both') e;
|
|
328
|
+
select e.agent_id into v_i from agent_board.enroll_federated_human(
|
|
329
|
+
v_iss, 'message-board', v_sub_i,
|
|
330
|
+
jsonb_build_object('sub', v_sub_i, 'email_verified', false), 'IssuerOnly') e;
|
|
331
|
+
select e.agent_id into v_ss from agent_board.enroll_federated_human(
|
|
332
|
+
v_iss, 'message-board', v_sub_s,
|
|
333
|
+
jsonb_build_object('sub', v_sub_s, 'email_verified', false), 'SubjectOnly') e;
|
|
334
|
+
select e.agent_id into v_x from agent_board.enroll_federated_human(
|
|
335
|
+
v_iss, 'message-board', v_sub_x,
|
|
336
|
+
jsonb_build_object('sub', v_sub_x, 'email_verified', false), 'Suspended') e;
|
|
337
|
+
select e.agent_id into v_n from agent_board.enroll_federated_human(
|
|
338
|
+
v_iss, 'message-board', v_sub_n,
|
|
339
|
+
jsonb_build_object('sub', v_sub_n, 'email_verified', false), 'NoStamp') e;
|
|
340
|
+
select e.agent_id into v_p from agent_board.enroll_federated_human(
|
|
341
|
+
v_iss, 'message-board', v_sub_p,
|
|
342
|
+
jsonb_build_object('sub', v_sub_p, 'email_verified', false), 'Partial') e;
|
|
343
|
+
select e.agent_id into v_q from agent_board.enroll_federated_human(
|
|
344
|
+
v_iss, 'message-board', v_sub_q,
|
|
345
|
+
jsonb_build_object('sub', v_sub_q, 'email_verified', false), 'NoIssuer') e;
|
|
346
|
+
|
|
347
|
+
-- THE CONSTRUCTION. Rewrite the stored stamp so it names a DIFFERENT principal
|
|
348
|
+
-- while the uid, handle and auth.users row still derive from the caller's pair --
|
|
349
|
+
-- the state a real collision produces, built directly (see the file header).
|
|
350
|
+
update agent_board.agents a
|
|
351
|
+
set metadata = jsonb_set(jsonb_set(a.metadata, '{federation,issuer}', to_jsonb(v_other_iss), true),
|
|
352
|
+
'{federation,subject}', to_jsonb(v_other_sub), true)
|
|
353
|
+
where a.id in (v_m, v_x);
|
|
354
|
+
-- Only the ISSUER differs (subject still matches the caller) -- pins that the
|
|
355
|
+
-- issuer conjunct is load-bearing on its own.
|
|
356
|
+
update agent_board.agents a
|
|
357
|
+
set metadata = jsonb_set(a.metadata, '{federation,issuer}', to_jsonb(v_other_iss), true)
|
|
358
|
+
where a.id = v_i;
|
|
359
|
+
-- Only the SUBJECT differs (issuer still matches) -- pins the subject conjunct.
|
|
360
|
+
update agent_board.agents a
|
|
361
|
+
set metadata = jsonb_set(a.metadata, '{federation,subject}', to_jsonb(v_other_sub), true)
|
|
362
|
+
where a.id = v_ss;
|
|
363
|
+
-- No stamp at all, and a stamp missing exactly one field: both must FAIL CLOSED.
|
|
364
|
+
update agent_board.agents a set metadata = a.metadata - 'federation' where a.id = v_n;
|
|
365
|
+
update agent_board.agents a
|
|
366
|
+
set metadata = jsonb_set(a.metadata, '{federation}', (a.metadata -> 'federation') - 'subject', true)
|
|
367
|
+
where a.id = v_p;
|
|
368
|
+
-- Q: stamp missing only `issuer`. The migration header claims fail-closed when the
|
|
369
|
+
-- stamp lacks issuer OR subject, and until this fixture existed only the subject
|
|
370
|
+
-- half had one -- a claim wider than any assertion behind it. Note the surviving
|
|
371
|
+
-- SUBJECT conjunct would refuse a genuinely different caller anyway, so this
|
|
372
|
+
-- fixture presents the CORRECT subject: the refusal can then only come from the
|
|
373
|
+
-- NULL issuer conjunct.
|
|
374
|
+
update agent_board.agents a
|
|
375
|
+
set metadata = jsonb_set(a.metadata, '{federation}', (a.metadata -> 'federation') - 'issuer', true)
|
|
376
|
+
where a.id = v_q;
|
|
377
|
+
-- X is additionally suspended, so its refusal can be compared against M's.
|
|
378
|
+
update agent_board.agents a set status = 'suspended', updated_at = now() where a.id = v_x;
|
|
379
|
+
|
|
380
|
+
-- Delete M's auto-join membership so a re-run of the return path WOULD recreate it
|
|
381
|
+
-- (auto-join is first-time-only; without this deletion EC-13 could not fail).
|
|
382
|
+
delete from agent_board.board_memberships bm where bm.agent_id = v_m and bm.board_id = v_board;
|
|
383
|
+
|
|
384
|
+
insert into _g values (
|
|
385
|
+
v_iss, 'message-board', v_board,
|
|
386
|
+
v_sub_l, v_l, v_hl, v_sub_m, v_m, v_sub_i, v_i, v_sub_s, v_ss,
|
|
387
|
+
v_sub_x, v_x, v_sub_n, v_n, v_sub_p, v_p, v_sub_q, v_q);
|
|
388
|
+
end
|
|
389
|
+
$$;
|
|
390
|
+
|
|
391
|
+
-- ---------------------------------------------------------------------------
|
|
392
|
+
-- Section B -- THE REGRESSION RISK: the legitimate owner must still be returned
|
|
393
|
+
-- ---------------------------------------------------------------------------
|
|
394
|
+
-- Asserted POSITIVELY and first, while every fixture is pristine. A careless
|
|
395
|
+
-- equality check breaks idempotency for a real owner, and that failure mode is worse
|
|
396
|
+
-- for this deployment than the collision it defends against.
|
|
397
|
+
create temp table _idem_before as
|
|
398
|
+
select count(*)::int as n from agent_board.enrollment_federation_audit e
|
|
399
|
+
where e.agent_id = (select agent_l from _g);
|
|
400
|
+
|
|
401
|
+
-- 13 -- #113: an allow-side call NEVER runs inside an is() value expression; a raise
|
|
402
|
+
-- there aborts the file with no `not ok`. lives_ok captures, then assertions read.
|
|
403
|
+
select lives_ok(
|
|
404
|
+
(select format($q$create temp table _idem as
|
|
405
|
+
select * from agent_board.enroll_federated_human(%L, 'message-board', %L, %L::jsonb, 'Legit')$q$,
|
|
406
|
+
iss, sub_l, jsonb_build_object('sub', sub_l, 'email_verified', false)::text) from _g),
|
|
407
|
+
'the legitimate owner re-presenting its OWN (issuer, subject) is accepted [call succeeds]');
|
|
408
|
+
|
|
409
|
+
-- 14
|
|
410
|
+
select is((select agent_id from _idem), (select agent_l from _g),
|
|
411
|
+
'the owner gets ITS OWN agent back (idempotency preserved)');
|
|
412
|
+
|
|
413
|
+
-- 15
|
|
414
|
+
select is((select kind || '/' || status from _idem), 'human/active',
|
|
415
|
+
'the returned identity is still an active human');
|
|
416
|
+
|
|
417
|
+
-- 16
|
|
418
|
+
select is((select handle from _idem), (select handle_l from _g),
|
|
419
|
+
'the returned handle is unchanged (the derivation is untouched by this change)');
|
|
420
|
+
|
|
421
|
+
-- 17 -- the idempotent return still appends its audit row: the gate refuses, it does
|
|
422
|
+
-- not silently degrade the accepted path into a no-op.
|
|
423
|
+
select is(
|
|
424
|
+
(select count(*)::int from agent_board.enrollment_federation_audit e
|
|
425
|
+
where e.agent_id = (select agent_l from _g)) - (select n from _idem_before),
|
|
426
|
+
1,
|
|
427
|
+
'the accepted re-provision appends exactly one audit row');
|
|
428
|
+
|
|
429
|
+
-- ---------------------------------------------------------------------------
|
|
430
|
+
-- Section C -- a MISMATCHED caller is refused, and each conjunct is load-bearing
|
|
431
|
+
-- ---------------------------------------------------------------------------
|
|
432
|
+
create temp table _audit_before as
|
|
433
|
+
select count(*)::int as n from agent_board.enrollment_federation_audit e
|
|
434
|
+
where e.agent_id = (select agent_m from _g);
|
|
435
|
+
|
|
436
|
+
-- 18
|
|
437
|
+
select throws_ok(
|
|
438
|
+
(select format($q$select agent_board.enroll_federated_human(%L, 'message-board', %L, %L::jsonb, null)$q$,
|
|
439
|
+
iss, sub_m, jsonb_build_object('sub', sub_m, 'email_verified', false)::text) from _g),
|
|
440
|
+
'42501', null,
|
|
441
|
+
'a caller whose pair differs from the stored stamp in BOTH fields is refused (42501)');
|
|
442
|
+
|
|
443
|
+
-- 19 -- issuer differs, subject matches. Fails if the issuer conjunct is dropped.
|
|
444
|
+
select throws_ok(
|
|
445
|
+
(select format($q$select agent_board.enroll_federated_human(%L, 'message-board', %L, %L::jsonb, null)$q$,
|
|
446
|
+
iss, sub_i, jsonb_build_object('sub', sub_i, 'email_verified', false)::text) from _g),
|
|
447
|
+
'42501', null,
|
|
448
|
+
'a caller whose stored stamp ISSUER differs (subject matches) is refused (42501)');
|
|
449
|
+
|
|
450
|
+
-- 20 -- subject differs, issuer matches. Fails if the subject conjunct is dropped.
|
|
451
|
+
select throws_ok(
|
|
452
|
+
(select format($q$select agent_board.enroll_federated_human(%L, 'message-board', %L, %L::jsonb, null)$q$,
|
|
453
|
+
iss, sub_s, jsonb_build_object('sub', sub_s, 'email_verified', false)::text) from _g),
|
|
454
|
+
'42501', null,
|
|
455
|
+
'a caller whose stored stamp SUBJECT differs (issuer matches) is refused (42501)');
|
|
456
|
+
|
|
457
|
+
-- Capture the actual refusal texts for the message + no-oracle assertions below.
|
|
458
|
+
do $$
|
|
459
|
+
declare r record; v_state text; v_msg text;
|
|
460
|
+
begin
|
|
461
|
+
select * into r from _g;
|
|
462
|
+
|
|
463
|
+
-- An email is supplied HERE deliberately. Step (7)'s accepted branch MERGES it into
|
|
464
|
+
-- the owner's auth.users.raw_user_meta_data before returning, so under the defect a
|
|
465
|
+
-- colliding caller writes ITS OWN email onto the VICTIM's owner row. EC-16 asserts
|
|
466
|
+
-- that did not happen.
|
|
467
|
+
begin
|
|
468
|
+
perform agent_board.enroll_federated_human(r.iss, 'message-board', r.sub_m,
|
|
469
|
+
jsonb_build_object('sub', r.sub_m, 'email', 'attacker-' || r.sub_m || '@example.test',
|
|
470
|
+
'email_verified', true), null);
|
|
471
|
+
exception when others then
|
|
472
|
+
get stacked diagnostics v_state = returned_sqlstate, v_msg = message_text;
|
|
473
|
+
insert into _err values ('mismatch_active', v_state, v_msg);
|
|
474
|
+
end;
|
|
475
|
+
|
|
476
|
+
begin
|
|
477
|
+
perform agent_board.enroll_federated_human(r.iss, 'message-board', r.sub_x,
|
|
478
|
+
jsonb_build_object('sub', r.sub_x, 'email_verified', false), null);
|
|
479
|
+
exception when others then
|
|
480
|
+
get stacked diagnostics v_state = returned_sqlstate, v_msg = message_text;
|
|
481
|
+
insert into _err values ('mismatch_suspended', v_state, v_msg);
|
|
482
|
+
end;
|
|
483
|
+
|
|
484
|
+
begin
|
|
485
|
+
perform agent_board.enroll_federated_human(r.iss, 'message-board', r.sub_n,
|
|
486
|
+
jsonb_build_object('sub', r.sub_n, 'email_verified', false), null);
|
|
487
|
+
exception when others then
|
|
488
|
+
get stacked diagnostics v_state = returned_sqlstate, v_msg = message_text;
|
|
489
|
+
insert into _err values ('no_stamp', v_state, v_msg);
|
|
490
|
+
end;
|
|
491
|
+
|
|
492
|
+
begin
|
|
493
|
+
perform agent_board.enroll_federated_human(r.iss, 'message-board', r.sub_p,
|
|
494
|
+
jsonb_build_object('sub', r.sub_p, 'email_verified', false), null);
|
|
495
|
+
exception when others then
|
|
496
|
+
get stacked diagnostics v_state = returned_sqlstate, v_msg = message_text;
|
|
497
|
+
insert into _err values ('partial_stamp', v_state, v_msg);
|
|
498
|
+
end;
|
|
499
|
+
|
|
500
|
+
begin
|
|
501
|
+
perform agent_board.enroll_federated_human(r.iss, 'message-board', r.sub_q,
|
|
502
|
+
jsonb_build_object('sub', r.sub_q, 'email_verified', false), null);
|
|
503
|
+
exception when others then
|
|
504
|
+
get stacked diagnostics v_state = returned_sqlstate, v_msg = message_text;
|
|
505
|
+
insert into _err values ('no_issuer', v_state, v_msg);
|
|
506
|
+
end;
|
|
507
|
+
end
|
|
508
|
+
$$;
|
|
509
|
+
|
|
510
|
+
-- 21 -- the refusal is the #246 refusal, not some other 42501 that happens to fire.
|
|
511
|
+
select matches(
|
|
512
|
+
(select msg from _err where label = 'mismatch_active'),
|
|
513
|
+
'^federated identity mismatch: ',
|
|
514
|
+
'the refusal is the #246 stored-identity refusal, identifiable by its own phrase');
|
|
515
|
+
|
|
516
|
+
-- 22 -- IT LEAKS NOTHING ABOUT THE OTHER PRINCIPAL. The message is echoed verbatim to
|
|
517
|
+
-- the UNTRUSTED caller: supabase/functions/portal-session/index.ts returns
|
|
518
|
+
-- {error:"provision_rejected", detail:<message>} with HTTP 403. So it must name no
|
|
519
|
+
-- handle, no agent id, and neither of the stored pair's values.
|
|
520
|
+
select ok(
|
|
521
|
+
(select msg from _err where label = 'mismatch_active')
|
|
522
|
+
not like '%' || (select metadata #>> '{federation,issuer}' from agent_board.agents where id = (select agent_m from _g)) || '%'
|
|
523
|
+
and (select msg from _err where label = 'mismatch_active')
|
|
524
|
+
not like '%' || (select metadata #>> '{federation,subject}' from agent_board.agents where id = (select agent_m from _g)) || '%'
|
|
525
|
+
and (select msg from _err where label = 'mismatch_active')
|
|
526
|
+
not like '%' || (select handle from agent_board.agents where id = (select agent_m from _g)) || '%'
|
|
527
|
+
and (select msg from _err where label = 'mismatch_active')
|
|
528
|
+
not like '%' || (select agent_m from _g)::text || '%',
|
|
529
|
+
'the refusal names neither the stored issuer/subject, nor the handle, nor the agent id of the row it refused');
|
|
530
|
+
|
|
531
|
+
-- ---------------------------------------------------------------------------
|
|
532
|
+
-- Section D -- the refusal has NO side effect, which is the damage #246 names
|
|
533
|
+
-- ---------------------------------------------------------------------------
|
|
534
|
+
-- 23 -- the audit row is the poisoning: under the defect, the mismatched caller's
|
|
535
|
+
-- (issuer, subject) was appended onto the VICTIM's agent_id, corrupting the stored
|
|
536
|
+
-- evidence enroll_federated_identity's #176/#244 resumption conjunction reads.
|
|
537
|
+
select is(
|
|
538
|
+
(select count(*)::int from agent_board.enrollment_federation_audit e
|
|
539
|
+
where e.agent_id = (select agent_m from _g)),
|
|
540
|
+
(select n from _audit_before),
|
|
541
|
+
'a refused call appends NO audit row onto the agent it refused (no evidence poisoning)');
|
|
542
|
+
|
|
543
|
+
-- 24 -- nor does the refused caller's EMAIL reach the victim's owner row. Step (7)'s
|
|
544
|
+
-- accepted branch merges `fedsession_email` into auth.users.raw_user_meta_data before
|
|
545
|
+
-- returning; under the defect that is the attacker's address written onto the owner
|
|
546
|
+
-- auth.users row of the identity it collided with.
|
|
547
|
+
select is(
|
|
548
|
+
(select count(*)::int from auth.users u, agent_board.agents a, _g g
|
|
549
|
+
where a.id = g.agent_m and u.id = a.auth_user_id
|
|
550
|
+
and u.raw_user_meta_data ->> 'fedsession_email' is not null),
|
|
551
|
+
0,
|
|
552
|
+
'a refused call writes no fedsession_email onto the owner auth.users row it refused');
|
|
553
|
+
|
|
554
|
+
-- 25 -- the auto-join side effect does not fire either. M's membership was deleted in
|
|
555
|
+
-- the fixture, so the return path WOULD recreate it (auto-join is first-time-only).
|
|
556
|
+
select is(
|
|
557
|
+
(select count(*)::int from agent_board.board_memberships bm, _g g
|
|
558
|
+
where bm.agent_id = g.agent_m and bm.board_id = g.board_id),
|
|
559
|
+
0,
|
|
560
|
+
'a refused call performs no default-board auto-join for the agent it refused');
|
|
561
|
+
|
|
562
|
+
-- ---------------------------------------------------------------------------
|
|
563
|
+
-- Section E -- NO ORACLE: the gate runs before the status and kind branches
|
|
564
|
+
-- ---------------------------------------------------------------------------
|
|
565
|
+
-- 26 -- X is mismatched AND suspended. If the gate were placed after the status
|
|
566
|
+
-- branch, this would be the 'a non-active identity already exists' refusal instead,
|
|
567
|
+
-- telling a colliding caller the victim's account state.
|
|
568
|
+
select is(
|
|
569
|
+
(select sqlstate || ' ' || msg from _err where label = 'mismatch_suspended'),
|
|
570
|
+
(select sqlstate || ' ' || msg from _err where label = 'mismatch_active'),
|
|
571
|
+
'a mismatched caller gets the IDENTICAL refusal whether the existing agent is active or suspended');
|
|
572
|
+
|
|
573
|
+
-- ---------------------------------------------------------------------------
|
|
574
|
+
-- Section F -- FAIL CLOSED on a missing or partial stamp
|
|
575
|
+
-- ---------------------------------------------------------------------------
|
|
576
|
+
-- Measured before choosing this posture: on the canonical project (2026-08-16) there
|
|
577
|
+
-- is 1 agent reachable on this path, 0 of them lack a stamp, and all 110 of its audit
|
|
578
|
+
-- rows agree with the stamp on both fields -- so refusing breaks no live owner. The
|
|
579
|
+
-- creation branch has written both fields unconditionally since 20260728000100:243.
|
|
580
|
+
-- 27
|
|
581
|
+
select is(
|
|
582
|
+
(select sqlstate || '|' || (msg like 'federated identity mismatch:%')::text
|
|
583
|
+
from _err where label = 'no_stamp'),
|
|
584
|
+
'42501|true',
|
|
585
|
+
'an agent with NO metadata->federation stamp is refused, not admitted (fail closed)');
|
|
586
|
+
|
|
587
|
+
-- 28
|
|
588
|
+
select is(
|
|
589
|
+
(select sqlstate || '|' || (msg like 'federated identity mismatch:%')::text
|
|
590
|
+
from _err where label = 'partial_stamp'),
|
|
591
|
+
'42501|true',
|
|
592
|
+
'an agent whose stamp is missing only `subject` is refused (a NULL conjunct reads as NOT-mine)');
|
|
593
|
+
|
|
594
|
+
-- 29 -- the missing-ISSUER-only half of the fail-closed claim. Added because the
|
|
595
|
+
-- migration header named it and no fixture covered it -- a claim wider than its
|
|
596
|
+
-- evidence, which is the exact shape this bundle argues against. The caller here
|
|
597
|
+
-- presents the CORRECT subject, so the surviving subject conjunct cannot be what
|
|
598
|
+
-- refuses: only the NULL issuer conjunct can.
|
|
599
|
+
select is(
|
|
600
|
+
(select sqlstate || '|' || (msg like 'federated identity mismatch:%')::text
|
|
601
|
+
from _err where label = 'no_issuer'),
|
|
602
|
+
'42501|true',
|
|
603
|
+
'an agent whose stamp is missing only `issuer` is refused, with the correct subject presented');
|
|
604
|
+
|
|
605
|
+
select * from finish();
|
|
606
|
+
rollback;
|