@consiliency/agent-board-schema 1.24.0 → 1.27.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/20260906000100_agent_board_enroll_resumption.sql +749 -0
- package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
- package/migrations/20260908000100_agent_board_signer_lifecycle_revalidation.sql +451 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +3 -3
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +129 -112
- package/tests/agent_board_claimkey.test.sql +25 -3
- package/tests/agent_board_consent_emission.test.sql +17 -13
- package/tests/agent_board_consent_gating.test.sql +55 -11
- package/tests/agent_board_delegation_policies.test.sql +3 -1
- package/tests/agent_board_enroll_resumption.test.sql +412 -0
- package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
- package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
- package/tests/agent_board_mint_harden.test.sql +39 -4
- package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
- package/tests/agent_board_rejectsem.test.sql +1 -1
- package/tests/agent_board_rolereset.test.sql +23 -2
- package/tests/agent_board_rowid.test.sql +24 -13
- package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
- package/tests/agent_board_signed_authority_provenance.test.sql +477 -0
- package/tests/agent_board_signer_lifecycle.test.sql +709 -0
- package/tests/agent_board_source_strip_helper.test.sql +285 -0
- package/tests/agent_board_wake_recovery.test.sql +58 -40
- package/tests/catalog/p_metadata_parameters.json +693 -0
- package/tests/fixtures/test-prereqs.sql +313 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +3 -3
- package/tests/isolation/expected/signed_grant_onboarding_deadlock.out +220 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +307 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +159 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +176 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
-- #269 — atomic signer-lifecycle revalidation for the two signed AUTHORITY wrappers.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS CLOSES. Portal verifies the Ed25519 signature, reads agents.status and the
|
|
4
|
+
-- signing key's lifecycle, and reserves replay — then calls
|
|
5
|
+
-- agent_board.signed_grant_board_role / signed_set_board_authority_ceiling in a SEPARATE
|
|
6
|
+
-- PostgREST round trip. A suspension or key revocation that commits between those two calls
|
|
7
|
+
-- is invisible to the wrapper: #247 (20260907000100) binds the gateway-verified actor and
|
|
8
|
+
-- rechecks BOARD authority, but reads nothing about the SIGNER's own lifecycle. Enumerated
|
|
9
|
+
-- from the shipped source rather than from memory: neither wrapper mentions agents.status,
|
|
10
|
+
-- agent_signing_keys, proof_state or expires_at. So a revoked or suspended owner completes an
|
|
11
|
+
-- authority mutation inside that window. Portal cannot make verification and the wrapper call
|
|
12
|
+
-- one transaction across two HTTP calls; the fix is schema-owned.
|
|
13
|
+
--
|
|
14
|
+
-- WHERE THE CHECK LIVES, AND WHY NOT IN _signed_write_actor_id.
|
|
15
|
+
-- _signed_write_actor_id (20260710000000) is the shared chokepoint of SEVEN wrappers
|
|
16
|
+
-- (enumerated from pg_proc: the five message/task wrappers of 20260628000003 and
|
|
17
|
+
-- 20260711000100, plus the two #247 authority wrappers). Extending IT is the tempting move
|
|
18
|
+
-- and it is wrong, for a reason that is empirical rather than stylistic: Portal's
|
|
19
|
+
-- dispatchSignedWrite still calls the three message wrappers when the write is NOT verified
|
|
20
|
+
-- and the enforcement mode allows unsigned (`observe`), with a decision whose signing_key_id
|
|
21
|
+
-- is null. A live-key requirement in the shared helper would fail-close that path — a
|
|
22
|
+
-- capability regression on five wrappers, none of which #269 is about.
|
|
23
|
+
--
|
|
24
|
+
-- So the revalidation is its own helper called by the two authority wrappers, and the CLASS
|
|
25
|
+
-- is pinned from the catalog instead of by hand: agent_board_signer_lifecycle.test.sql
|
|
26
|
+
-- asserts that among all `signed\_%` wrappers, the set whose stripped body calls an authority
|
|
27
|
+
-- chokepoint (grant_board_role / set_board_authority_ceiling / set_board_role_gate) is
|
|
28
|
+
-- EXACTLY the set whose stripped body calls this helper — both directions. A future signed
|
|
29
|
+
-- authority wrapper that forgets the call fails that assertion and names itself.
|
|
30
|
+
--
|
|
31
|
+
-- WHY THE CALL SITE IS AFTER THE INNER FUNCTION, NOT BEFORE. Two independent reasons; do not
|
|
32
|
+
-- "optimize" it earlier.
|
|
33
|
+
--
|
|
34
|
+
-- 1. LOCK ORDER. The revalidation takes agents then agent_signing_keys. Placed AFTER the
|
|
35
|
+
-- inner call, the ceiling wrapper's total order is
|
|
36
|
+
-- advisory(board) -> boards -> agents -> agent_signing_keys
|
|
37
|
+
-- which is onboard_federated_fleet_agent's order verbatim (20260901000110: advisory
|
|
38
|
+
-- hoisted above the boards row lock; 20260901000100:1277 locks agent before key). Placed
|
|
39
|
+
-- BEFORE, it becomes agents -> advisory -> boards, inverting against onboarding on
|
|
40
|
+
-- (advisory, agents) — and the two transactions plausibly share both operands, because
|
|
41
|
+
-- the fleet invite creator and the board owner signing a ceiling change are routinely
|
|
42
|
+
-- the same agent on the same board. That is the exact ABBA shape 20260901000110
|
|
43
|
+
-- documents having introduced and then fixed.
|
|
44
|
+
--
|
|
45
|
+
-- THAT ARGUMENT WAS RIGHT ABOUT THE CEILING WRAPPER AND WRONG ABOUT THE GRANT WRAPPER,
|
|
46
|
+
-- which does not reach the advisory lock at all. See TOTAL ORDERS below: the grant
|
|
47
|
+
-- wrapper's own order was board_memberships -> agents, an ABBA against onboarding that
|
|
48
|
+
-- the "verbatim" claim above obscured by reasoning only about the ceiling path. It is
|
|
49
|
+
-- fixed by acquiring the board advisory lock in the grant wrapper, not by moving the
|
|
50
|
+
-- revalidation.
|
|
51
|
+
-- 2. IT PRESERVES EVERY EXISTING #247 ASSERTION. Two of them drive a wrapper with a
|
|
52
|
+
-- decision whose agent_id is agent 3 while signing_key_id is agent 1's key, and assert
|
|
53
|
+
-- the INNER gate's message ('%exceeds grantor role%', '%only a board owner%'). Checking
|
|
54
|
+
-- first would raise signer_key_not_owned before the inner gate ever runs and rewrite
|
|
55
|
+
-- both.
|
|
56
|
+
--
|
|
57
|
+
-- Correctness is unaffected by the position: the wrapper is one transaction, so a raise here
|
|
58
|
+
-- rolls back the inner write. The three interleavings are all fail-closed — a revocation that
|
|
59
|
+
-- committed earlier is SEEN (the row lock reads the latest committed row), one that starts
|
|
60
|
+
-- later BLOCKS on the share lock until this transaction commits, and one already in flight
|
|
61
|
+
-- makes this transaction block and then observe the revoked row.
|
|
62
|
+
--
|
|
63
|
+
-- TOTAL ORDERS, stated explicitly so no future reader has to reconstruct them from two
|
|
64
|
+
-- migrations. Every lock either authority wrapper takes, in acquisition order:
|
|
65
|
+
--
|
|
66
|
+
-- signed_grant_board_role
|
|
67
|
+
-- advisory(board) -> board_memberships(grantee, UPDATE) -> agents(signer, FOR SHARE)
|
|
68
|
+
-- -> agent_signing_keys(decision key, FOR SHARE)
|
|
69
|
+
-- signed_set_board_authority_ceiling
|
|
70
|
+
-- advisory(board) -> board_authority_ceilings (+ boards via FK) -> agents(signer, FOR SHARE)
|
|
71
|
+
-- -> agent_signing_keys(decision key, FOR SHARE)
|
|
72
|
+
-- onboard_federated_fleet_agent (20260901000110, for comparison)
|
|
73
|
+
-- board_invite_tokens(:274) -> fleet_onboarding_invite_bounds(:281) -> advisory(board)(:290)
|
|
74
|
+
-- -> boards -> fleet_machines -> fleet_trust_domains -> advisory(fingerprint)
|
|
75
|
+
-- -> auth.users -> agents(:349) -> board_memberships(creator)(:367)
|
|
76
|
+
-- -> board_authority_ceilings(:369) -> board_memberships(enrollee)(:397)
|
|
77
|
+
-- -> agents(enrolled)(:543) -> agent_signing_keys(:544)
|
|
78
|
+
--
|
|
79
|
+
-- WHY THIS IS ACYCLIC, and why the argument is mutual exclusion rather than ordering. Both
|
|
80
|
+
-- wrappers take advisory(board) as their FIRST lock. Onboarding requests it holding only
|
|
81
|
+
-- board_invite_tokens and fleet_onboarding_invite_bounds, two relations no authority wrapper
|
|
82
|
+
-- ever touches. So on any shared board the three transactions are serialised at the advisory
|
|
83
|
+
-- lock, and nothing either does afterwards can close a cycle. An ordering argument would have
|
|
84
|
+
-- to be re-verified every time a lock is added anywhere downstream; this one does not.
|
|
85
|
+
--
|
|
86
|
+
-- THE CEILING WRAPPER NEEDS NO CODE CHANGE — its inner set_board_authority_ceiling already
|
|
87
|
+
-- takes the advisory lock first (20260901000110:146, preserved through the #247 recreation).
|
|
88
|
+
-- Only the grant wrapper had to acquire it. Do not "symmetrise" this by adding a second
|
|
89
|
+
-- acquisition to the ceiling wrapper: it would be redundant, not safer.
|
|
90
|
+
--
|
|
91
|
+
-- LOCK MODE — FOR SHARE, on both rows, and neither weaker nor stronger.
|
|
92
|
+
-- * FOR KEY SHARE is too weak. Every lifecycle write here is a plain UPDATE of a non-key
|
|
93
|
+
-- column (agents.status, agent_signing_keys.status), which takes FOR NO KEY UPDATE — and
|
|
94
|
+
-- FOR NO KEY UPDATE does NOT conflict with FOR KEY SHARE. The suspend would sail past.
|
|
95
|
+
-- * FOR UPDATE is too strong, and the cost is not hypothetical: FOR UPDATE conflicts with
|
|
96
|
+
-- FOR KEY SHARE, which is what EVERY concurrent insert of a row referencing this agent
|
|
97
|
+
-- takes through its foreign key (a message, an inbox item, a membership). One signed
|
|
98
|
+
-- authority write would block unrelated traffic for its whole transaction. FOR SHARE
|
|
99
|
+
-- conflicts with FOR NO KEY UPDATE and FOR UPDATE — exactly the lifecycle writers — and
|
|
100
|
+
-- not with FOR KEY SHARE, so FK traffic is untouched. It also lets two concurrent signed
|
|
101
|
+
-- writes by the same signer proceed, which FOR UPDATE would needlessly serialize.
|
|
102
|
+
--
|
|
103
|
+
-- LOCK ORDER — agents BEFORE agent_signing_keys, taken from the NEWEST definitions, not the
|
|
104
|
+
-- first. 20260718001100's _revoke_agent_principal locked keys first; the live one
|
|
105
|
+
-- (20260901000100:1277) locks `agents ... for update` and THEN loops the keys in id order,
|
|
106
|
+
-- and revoke_fleet_subject's signing_key branch does the same (agent, then key). Matching the
|
|
107
|
+
-- transaction this gate exists to serialize against is the whole point, so this helper takes
|
|
108
|
+
-- agents first too.
|
|
109
|
+
--
|
|
110
|
+
-- KNOWN AND STATED: today every RPC that REVOKES a key (_revoke_agent_principal,
|
|
111
|
+
-- revoke_fleet_subject) locks the agent row first, so the agents lock alone would already
|
|
112
|
+
-- serialize them. The agent_signing_keys FOR SHARE is load-bearing against a key-ONLY writer:
|
|
113
|
+
-- issue_signing_key_challenge locks the key row and no agent row, and the table's UPDATE
|
|
114
|
+
-- privilege is held by the owner alone (service_role has SELECT only), so owner-level
|
|
115
|
+
-- incident DML is the other. The isolation spec pins that lock with the challenge RPC rather
|
|
116
|
+
-- than claiming it from the code.
|
|
117
|
+
--
|
|
118
|
+
-- WHAT IS CHECKED, AND WHAT IS DELIBERATELY NOT RE-CHECKED. The predicate is: the decision's
|
|
119
|
+
-- verification_status is EXACTLY 'verified'; agents.status = 'active'; the key row identified
|
|
120
|
+
-- by the decision exists, is owned by that same actor, has status = 'active', and is
|
|
121
|
+
-- unexpired.
|
|
122
|
+
--
|
|
123
|
+
-- THE verification_status HALF, AND THE ARGUMENT IT REPLACES. The first version of this
|
|
124
|
+
-- helper checked no outcome at all, and the header argued that refusing an ABSENT key was
|
|
125
|
+
-- sufficient because "granting a role is not an operation with an observe-only meaning".
|
|
126
|
+
-- That argument was wrong in a way worth recording: key-absence is a PROXY for unsigned, and
|
|
127
|
+
-- the proxy leaks. A verification that FAILED plausibly still names the key it resolved, so a
|
|
128
|
+
-- decision reading {agent_id: <live owner>, signing_key_id: <live key>,
|
|
129
|
+
-- verification_status: 'failed'} satisfied every check in the chain -- _signed_write_actor_id
|
|
130
|
+
-- (20260710000000:53-63) asks only that an actor be named, this helper asked only that a key
|
|
131
|
+
-- be named and live, and _record_signed_write_evidence explicitly ADMITS 'failed'
|
|
132
|
+
-- (20260710000000:88). Reproduced on a database built from the manifest: the grant COMPLETED
|
|
133
|
+
-- and the evidence row recorded 'failed' beside a mutation that had succeeded. The proxy is
|
|
134
|
+
-- now a second line behind the outcome itself.
|
|
135
|
+
--
|
|
136
|
+
-- WHY EXACTLY 'verified' AND NOT A SET. The column vocabulary is
|
|
137
|
+
-- ('unsigned','observed','warn','verified','failed') -- the CHECK at 20260628000000:89,
|
|
138
|
+
-- mirrored by the client's catalog_vocabularies.ts. It is tempting to admit 'warn' as a
|
|
139
|
+
-- "verified with a caveat", and that reading is simply false:
|
|
140
|
+
-- specs/agent-provenance-signing.md:258-259 defines 'observed' and 'warn' as the labels an
|
|
141
|
+
-- UNSIGNED write receives under enforcement_mode 'observe' and 'warn'. They are enforcement
|
|
142
|
+
-- artifacts, not verification outcomes, so admitting either would re-open this hole under a
|
|
143
|
+
-- different name. 'verified' is the only value meaning a signature was checked and passed.
|
|
144
|
+
-- The comparison is `is distinct from`, so absent, empty and unknown values fail CLOSED in
|
|
145
|
+
-- the same branch -- including the values of the DIFFERENT, client-side
|
|
146
|
+
-- SigningVerificationStatus vocabulary ('signed', 'valid_signature': contract.ts:268), which
|
|
147
|
+
-- must never satisfy a gate written against the evidence vocabulary.
|
|
148
|
+
--
|
|
149
|
+
-- NOT HOISTED INTO _signed_write_actor_id, for the reason the next section gives at length:
|
|
150
|
+
-- the five message/task wrappers legitimately dispatch unsigned under 'observe'. This gate is
|
|
151
|
+
-- authority-only, and the class assertion in agent_board_signer_lifecycle.test.sql section 6
|
|
152
|
+
-- is what keeps "authority-only" true as wrappers are added.
|
|
153
|
+
--
|
|
154
|
+
-- The two fleet-policy call sites that accept ('verified','warn','observed')
|
|
155
|
+
-- (20260628000005:305, 20260826000100:242, 20260629000101:305) are a DIFFERENT function class
|
|
156
|
+
-- with a weaker bar and are deliberately not touched here. `proof_state = 'proven'` and
|
|
157
|
+
-- `revoked_at is null` are NOT repeated in the predicate: the table CHECKs
|
|
158
|
+
-- agent_signing_keys_active_requires_proof (status <> 'active' or proof_state = 'proven') and
|
|
159
|
+
-- agent_signing_keys_revoked_shape (revoked_at is not null exactly when status is
|
|
160
|
+
-- revoked/compromised) make both consequences of status = 'active'. Restating them would add
|
|
161
|
+
-- two branches that NO mutation could make an assertion fail on — an unpinnable construct
|
|
162
|
+
-- (CLAUDE.md rule 2). The reliance is asserted instead: the pgTAP file proves each CHECK
|
|
163
|
+
-- positively, by trying to write an active-unproven and an active-with-revoked_at row and
|
|
164
|
+
-- requiring the constraint to reject it, so dropping a CHECK fails the suite rather than
|
|
165
|
+
-- silently unguarding this predicate.
|
|
166
|
+
--
|
|
167
|
+
-- WHICH IDENTIFIER NAMES THE KEY. `signing_key_id` — the primary key — not key_id/key_version.
|
|
168
|
+
-- It is the row identity Portal already resolved to verify the signature and the one it hands
|
|
169
|
+
-- reserve_signed_write_replay(p_signing_key_id), so it is the only identifier that names the
|
|
170
|
+
-- SAME row the signature was checked against. key_id and key_version are unique only PER
|
|
171
|
+
-- AGENT, so resolving by them would need the actor to disambiguate and would silently pick a
|
|
172
|
+
-- different row if the decision's actor and key disagreed. They are still validated, as a
|
|
173
|
+
-- consistency assertion: _record_signed_write_evidence copies key_id and key_version from the
|
|
174
|
+
-- decision into signature_evidence verbatim, so a decision whose text identifiers disagree
|
|
175
|
+
-- with the row this helper just revalidated would mint an evidence row naming a key that was
|
|
176
|
+
-- never checked. That is refused, not recorded.
|
|
177
|
+
--
|
|
178
|
+
-- BLAST RADIUS. Zero on the five pre-existing wrappers: _signed_write_actor_id is untouched
|
|
179
|
+
-- and they gain no call. The two authority wrappers keep their (jsonb, jsonb) signature, so
|
|
180
|
+
-- CREATE OR REPLACE preserves their ACLs; their COMMENTs are restated because their behaviour
|
|
181
|
+
-- changed. The new helper is a fresh CREATE and therefore restates revoke/grant/comment in
|
|
182
|
+
-- this same migration — a SECURITY DEFINER function grants EXECUTE to PUBLIC by default.
|
|
183
|
+
|
|
184
|
+
-- 1. The revalidation chokepoint.
|
|
185
|
+
create or replace function agent_board._assert_signed_authority_signer_live(
|
|
186
|
+
p_actor_agent_id uuid,
|
|
187
|
+
p_gateway_decision jsonb
|
|
188
|
+
)
|
|
189
|
+
returns void
|
|
190
|
+
language plpgsql
|
|
191
|
+
security definer
|
|
192
|
+
set search_path = agent_board, public
|
|
193
|
+
as $$
|
|
194
|
+
declare
|
|
195
|
+
v_signing_key_id uuid := nullif(p_gateway_decision ->> 'signing_key_id', '')::uuid;
|
|
196
|
+
v_decision_key_id text := nullif(p_gateway_decision ->> 'key_id', '');
|
|
197
|
+
v_decision_key_version integer := nullif(p_gateway_decision ->> 'key_version', '')::integer;
|
|
198
|
+
v_verification_status text := nullif(p_gateway_decision ->> 'verification_status', '');
|
|
199
|
+
v_agent_status text;
|
|
200
|
+
v_key record;
|
|
201
|
+
begin
|
|
202
|
+
if p_actor_agent_id is null then
|
|
203
|
+
raise exception 'signer_not_active: signed authority write requires a resolved signer'
|
|
204
|
+
using errcode = '42501';
|
|
205
|
+
end if;
|
|
206
|
+
|
|
207
|
+
-- THE OUTCOME ITSELF, checked before anything else. Everything below revalidates the
|
|
208
|
+
-- signer's LIFECYCLE; none of it asks whether the signature actually verified, and the
|
|
209
|
+
-- gateway's own verdict is the only thing that can answer that. Refusing an absent key
|
|
210
|
+
-- (below) is a PROXY for "unsigned" and it leaks: a verification that FAILED plausibly
|
|
211
|
+
-- still names the key it resolved, so a decision carrying verification_status='failed'
|
|
212
|
+
-- beside a live signer and a live key passed every other check here and COMPLETED the
|
|
213
|
+
-- authority mutation, leaving an evidence row that recorded 'failed' next to a write that
|
|
214
|
+
-- succeeded. Checked first, and before any row lock, because an unverified decision has no
|
|
215
|
+
-- standing regardless of whose key it names.
|
|
216
|
+
--
|
|
217
|
+
-- EXACTLY 'verified', not a set. The column vocabulary is
|
|
218
|
+
-- ('unsigned','observed','warn','verified','failed') (20260628000000:89, mirrored in the
|
|
219
|
+
-- client's catalog_vocabularies.ts). Of those, 'observed' and 'warn' are NOT weaker
|
|
220
|
+
-- verifications -- specs/agent-provenance-signing.md:258-259 defines both as the labels an
|
|
221
|
+
-- UNSIGNED write receives under enforcement_mode 'observe' and 'warn' respectively. So
|
|
222
|
+
-- 'verified' is the only value that means a signature was checked and passed, and the
|
|
223
|
+
-- accepted set has exactly one member. `is distinct from` makes absent, empty and unknown
|
|
224
|
+
-- fail CLOSED in the same branch -- which also covers the client-side
|
|
225
|
+
-- SigningVerificationStatus vocabulary ('signed', 'valid_signature': contract.ts:268), a
|
|
226
|
+
-- DIFFERENT vocabulary whose values must never satisfy this gate.
|
|
227
|
+
if v_verification_status is distinct from 'verified' then
|
|
228
|
+
raise exception 'signer_not_verified: signed authority write requires a verified gateway decision, got %',
|
|
229
|
+
coalesce(v_verification_status, '(absent)')
|
|
230
|
+
using errcode = '42501';
|
|
231
|
+
end if;
|
|
232
|
+
|
|
233
|
+
-- A signed AUTHORITY write must name the key it was signed with. The message wrappers
|
|
234
|
+
-- deliberately accept an unsigned decision under enforcement_mode='observe'; granting a
|
|
235
|
+
-- role is not an operation with an observe-only meaning, so an absent key is refused here
|
|
236
|
+
-- rather than recorded as 'unsigned'. This is now a SECOND line behind the verification
|
|
237
|
+
-- check above, not the thing standing in for it.
|
|
238
|
+
if v_signing_key_id is null then
|
|
239
|
+
raise exception 'signer_key_absent: signed authority write requires a gateway-verified signing key'
|
|
240
|
+
using errcode = '42501';
|
|
241
|
+
end if;
|
|
242
|
+
|
|
243
|
+
-- agents BEFORE agent_signing_keys (see header). FOR SHARE, not FOR UPDATE.
|
|
244
|
+
select a.status
|
|
245
|
+
into v_agent_status
|
|
246
|
+
from agent_board.agents a
|
|
247
|
+
where a.id = p_actor_agent_id
|
|
248
|
+
for share;
|
|
249
|
+
|
|
250
|
+
if not found then
|
|
251
|
+
raise exception 'signer_not_active: signed authority write signer is not a known agent'
|
|
252
|
+
using errcode = '42501';
|
|
253
|
+
end if;
|
|
254
|
+
if v_agent_status <> 'active' then
|
|
255
|
+
raise exception 'signer_not_active: signed authority write signer is %, not active', v_agent_status
|
|
256
|
+
using errcode = '42501';
|
|
257
|
+
end if;
|
|
258
|
+
|
|
259
|
+
select k.agent_id, k.key_id, k.key_version, k.status, k.expires_at
|
|
260
|
+
into v_key
|
|
261
|
+
from agent_board.agent_signing_keys k
|
|
262
|
+
where k.id = v_signing_key_id
|
|
263
|
+
for share;
|
|
264
|
+
|
|
265
|
+
if not found then
|
|
266
|
+
raise exception 'signer_key_absent: signed authority write names a signing key that does not exist'
|
|
267
|
+
using errcode = '42501';
|
|
268
|
+
end if;
|
|
269
|
+
|
|
270
|
+
-- The ownership half. A key that is perfectly live but belongs to somebody else authorizes
|
|
271
|
+
-- nobody: without this, a decision could pair actor A with agent B's healthy key and pass
|
|
272
|
+
-- the liveness predicate on a key A never held.
|
|
273
|
+
if v_key.agent_id <> p_actor_agent_id then
|
|
274
|
+
raise exception 'signer_key_not_owned: signed authority write signing key belongs to another agent'
|
|
275
|
+
using errcode = '42501';
|
|
276
|
+
end if;
|
|
277
|
+
|
|
278
|
+
if v_key.status <> 'active' then
|
|
279
|
+
raise exception 'signer_key_not_live: signed authority write signing key is %, not active', v_key.status
|
|
280
|
+
using errcode = '42501';
|
|
281
|
+
end if;
|
|
282
|
+
|
|
283
|
+
if v_key.expires_at is not null and v_key.expires_at <= now() then
|
|
284
|
+
raise exception 'signer_key_expired: signed authority write signing key expired at %', v_key.expires_at
|
|
285
|
+
using errcode = '42501';
|
|
286
|
+
end if;
|
|
287
|
+
|
|
288
|
+
-- Evidence integrity: the identifiers copied into signature_evidence must name the row that
|
|
289
|
+
-- was just revalidated.
|
|
290
|
+
if v_decision_key_id is not null and v_decision_key_id <> v_key.key_id then
|
|
291
|
+
raise exception 'signer_key_mismatch: gateway decision key_id does not match the revalidated signing key'
|
|
292
|
+
using errcode = '42501';
|
|
293
|
+
end if;
|
|
294
|
+
if v_decision_key_version is not null and v_decision_key_version <> v_key.key_version then
|
|
295
|
+
raise exception 'signer_key_mismatch: gateway decision key_version does not match the revalidated signing key'
|
|
296
|
+
using errcode = '42501';
|
|
297
|
+
end if;
|
|
298
|
+
end;
|
|
299
|
+
$$;
|
|
300
|
+
|
|
301
|
+
comment on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) is
|
|
302
|
+
'#269 signer-lifecycle revalidation for the signed AUTHORITY wrappers. Requires the gateway decision''s verification_status to be EXACTLY ''verified'' before anything else and before any row lock (''observed'' and ''warn'' label UNSIGNED writes accepted under those enforcement modes, not weaker verifications, so the accepted set has one member; absent/empty/unknown fail closed). Then, inside the wrapper''s own transaction, takes FOR SHARE on the signer''s agents row and then on the agent_signing_keys row named by p_gateway_decision->>''signing_key_id'' (agents first, matching _revoke_agent_principal and revoke_fleet_subject), and requires the agent to be active and the key to exist, be owned by that same agent, be active and unexpired, with key_id/key_version agreeing with the decision. FOR SHARE is chosen deliberately: FOR KEY SHARE would not conflict with the FOR NO KEY UPDATE a status change takes, and FOR UPDATE would block every concurrent FK insert referencing the agent. proof_state=''proven'' and revoked_at is null are implied by the agent_signing_keys_active_requires_proof and agent_signing_keys_revoked_shape CHECKs and are asserted there rather than duplicated here. Raises 42501 with a signer_not_active / signer_key_absent / signer_key_not_owned / signer_key_not_live / signer_key_expired / signer_key_mismatch prefix. Internal: it locks rows for an arbitrary agent id with no authorization of its own.';
|
|
303
|
+
|
|
304
|
+
revoke all on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) from public, anon, authenticated;
|
|
305
|
+
grant execute on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) to service_role;
|
|
306
|
+
|
|
307
|
+
-- 2. signed_grant_board_role — verbatim from 20260907000100 plus the revalidation, placed
|
|
308
|
+
-- after the inner grant and before the evidence row.
|
|
309
|
+
create or replace function agent_board.signed_grant_board_role(
|
|
310
|
+
p_payload jsonb,
|
|
311
|
+
p_gateway_decision jsonb
|
|
312
|
+
)
|
|
313
|
+
returns table(
|
|
314
|
+
board_id uuid,
|
|
315
|
+
agent_id uuid,
|
|
316
|
+
role text,
|
|
317
|
+
signature_evidence_id uuid,
|
|
318
|
+
verification_status text,
|
|
319
|
+
updated_at timestamptz
|
|
320
|
+
)
|
|
321
|
+
language plpgsql
|
|
322
|
+
security definer
|
|
323
|
+
set search_path = agent_board, public
|
|
324
|
+
as $$
|
|
325
|
+
declare
|
|
326
|
+
v_row record;
|
|
327
|
+
v_actor uuid;
|
|
328
|
+
v_evidence_id uuid;
|
|
329
|
+
v_board uuid := (p_payload ->> 'p_board_id')::uuid;
|
|
330
|
+
v_grantee uuid := (p_payload ->> 'p_agent_id')::uuid;
|
|
331
|
+
begin
|
|
332
|
+
-- The grantor is the gateway-verified signer. _signed_write_actor_id raises 42501 when
|
|
333
|
+
-- the gateway names no actor, and raises actor_mismatch when the payload names a
|
|
334
|
+
-- DIFFERENT one -- so a payload p_grantor_agent_id can never become the grantor. This
|
|
335
|
+
-- value is what grant_board_role binds (through its own _resolved_actor_agent_id) and
|
|
336
|
+
-- what the evidence row names; they cannot diverge.
|
|
337
|
+
v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_grantor_agent_id');
|
|
338
|
+
|
|
339
|
+
-- BOARD ADVISORY LOCK FIRST -- this is what makes the wrapper acyclic against
|
|
340
|
+
-- onboard_federated_fleet_agent. Without it the wrapper's order is
|
|
341
|
+
-- board_memberships(grantee) -> agents(signer), while onboarding takes
|
|
342
|
+
-- agents(20260901000110:349) -> board_memberships(:397): a genuine ABBA between two HONEST
|
|
343
|
+
-- callers, reproduced as `deadlock detected` on board_memberships with an owner who both
|
|
344
|
+
-- created a fleet invite and concurrently grants the enrollee a role -- routine operands on
|
|
345
|
+
-- one board. The inner grant_board_role takes no advisory lock (the catalog-pinned set of
|
|
346
|
+
-- ceiling-advisory takers is exactly onboard_federated_fleet_agent and
|
|
347
|
+
-- set_board_authority_ceiling), so the wrapper must take it.
|
|
348
|
+
--
|
|
349
|
+
-- Mutual exclusion, NOT a re-derived ordering. Both authority wrappers now take this as
|
|
350
|
+
-- their FIRST lock; onboarding takes it at :290 holding only board_invite_tokens and
|
|
351
|
+
-- fleet_onboarding_invite_bounds -- relations no authority wrapper ever touches -- so the
|
|
352
|
+
-- two cannot form a cycle whatever either does afterwards. That property survives a new lock
|
|
353
|
+
-- being added downstream, which an ordering argument would not.
|
|
354
|
+
--
|
|
355
|
+
-- Cost, accepted: a signed grant now serialises with onboarding and with ceiling writes on
|
|
356
|
+
-- the SAME board. Boards are the unit of contention and these are all authority mutations.
|
|
357
|
+
perform agent_board._lock_board_authority_ceiling(v_board);
|
|
358
|
+
|
|
359
|
+
select *
|
|
360
|
+
into v_row
|
|
361
|
+
from agent_board.grant_board_role(
|
|
362
|
+
v_board,
|
|
363
|
+
v_grantee,
|
|
364
|
+
p_payload ->> 'p_grantee_role',
|
|
365
|
+
v_actor
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
-- #269. The gateway's decision was made in an EARLIER transaction; a suspension or key
|
|
369
|
+
-- revocation can have committed since. Revalidate the signer under a share lock so a
|
|
370
|
+
-- concurrent revoke serializes against this write instead of racing it. After the inner
|
|
371
|
+
-- call by design (lock order, and it keeps the inner gates' errors first) -- same
|
|
372
|
+
-- transaction, so a raise here rolls the grant back.
|
|
373
|
+
perform agent_board._assert_signed_authority_signer_live(v_actor, p_gateway_decision);
|
|
374
|
+
|
|
375
|
+
v_evidence_id := agent_board._record_signed_write_evidence(
|
|
376
|
+
'external',
|
|
377
|
+
null,
|
|
378
|
+
'grant_board_role',
|
|
379
|
+
p_payload,
|
|
380
|
+
p_gateway_decision || jsonb_build_object(
|
|
381
|
+
'external_evidence_ref',
|
|
382
|
+
'board-authority:grant_board_role:' || v_board::text || ':' || v_grantee::text
|
|
383
|
+
)
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
return query
|
|
387
|
+
select v_row.board_id, v_row.agent_id, v_row.role, v_evidence_id,
|
|
388
|
+
coalesce(p_gateway_decision ->> 'verification_status', 'unsigned'), v_row.updated_at;
|
|
389
|
+
end;
|
|
390
|
+
$$;
|
|
391
|
+
|
|
392
|
+
comment on function agent_board.signed_grant_board_role(jsonb, jsonb) is
|
|
393
|
+
'#247 signed-write gateway wrapper for grant_board_role. Takes the board-keyed _lock_board_authority_ceiling advisory lock FIRST, before the inner grant: without it the wrapper''s order is board_memberships(grantee) then agents(signer) while onboard_federated_fleet_agent takes agents then board_memberships, a reproduced ABBA deadlock between two honest callers. Both authority wrappers now take that advisory lock first, and onboarding requests it holding only invite-scoped rows no wrapper touches, so they are serialised per board rather than merely ordered. Resolves the grantor with _signed_write_actor_id (gateway actor required; a payload p_grantor_agent_id that differs raises actor_mismatch 42501), passes THAT principal to grant_board_role so the bound grantor and the recorded one cannot diverge, and records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref naming the board and grantee. #269: after the grant and before the evidence row it calls _assert_signed_authority_signer_live, which requires the decision''s verification_status to be exactly ''verified'' (so a FAILED verification can no longer complete the grant and record ''failed'' beside it), then share-locks and revalidates the signer''s agents row and the decision''s signing key inside this transaction, so a suspension or key revocation racing the gateway''s separate verification call either serializes behind this write or fails it closed. Adds no authority: grant_board_role''s membership, ceiling and TOCTOU gates are untouched. service_role only, like every other signed_* wrapper (#118).';
|
|
394
|
+
|
|
395
|
+
-- 3. signed_set_board_authority_ceiling — same change, same position.
|
|
396
|
+
create or replace function agent_board.signed_set_board_authority_ceiling(
|
|
397
|
+
p_payload jsonb,
|
|
398
|
+
p_gateway_decision jsonb
|
|
399
|
+
)
|
|
400
|
+
returns table(
|
|
401
|
+
ceiling_board_id uuid,
|
|
402
|
+
ceiling_max_grantable_role text,
|
|
403
|
+
signature_evidence_id uuid,
|
|
404
|
+
verification_status text
|
|
405
|
+
)
|
|
406
|
+
language plpgsql
|
|
407
|
+
security definer
|
|
408
|
+
set search_path = agent_board, public
|
|
409
|
+
as $$
|
|
410
|
+
declare
|
|
411
|
+
v_row record;
|
|
412
|
+
v_actor uuid;
|
|
413
|
+
v_evidence_id uuid;
|
|
414
|
+
v_board uuid := (p_payload ->> 'p_board_id')::uuid;
|
|
415
|
+
begin
|
|
416
|
+
v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_actor_agent_id');
|
|
417
|
+
|
|
418
|
+
select *
|
|
419
|
+
into v_row
|
|
420
|
+
from agent_board.set_board_authority_ceiling(
|
|
421
|
+
v_board,
|
|
422
|
+
p_payload ->> 'p_max_grantable_role',
|
|
423
|
+
v_actor
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
-- #269. See signed_grant_board_role. The position matters most here: the inner setter takes
|
|
427
|
+
-- the board-keyed advisory lock and then reaches boards through the ceiling FK, so
|
|
428
|
+
-- revalidating afterwards gives advisory -> boards -> agents -> keys, which is
|
|
429
|
+
-- onboard_federated_fleet_agent's order. Hoisting this above the inner call would invert
|
|
430
|
+
-- against it on (advisory, agents).
|
|
431
|
+
perform agent_board._assert_signed_authority_signer_live(v_actor, p_gateway_decision);
|
|
432
|
+
|
|
433
|
+
v_evidence_id := agent_board._record_signed_write_evidence(
|
|
434
|
+
'external',
|
|
435
|
+
null,
|
|
436
|
+
'set_board_authority_ceiling',
|
|
437
|
+
p_payload,
|
|
438
|
+
p_gateway_decision || jsonb_build_object(
|
|
439
|
+
'external_evidence_ref',
|
|
440
|
+
'board-authority:set_board_authority_ceiling:' || v_board::text || ':' || v_actor::text
|
|
441
|
+
)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return query
|
|
445
|
+
select v_row.ceiling_board_id, v_row.ceiling_max_grantable_role, v_evidence_id,
|
|
446
|
+
coalesce(p_gateway_decision ->> 'verification_status', 'unsigned');
|
|
447
|
+
end;
|
|
448
|
+
$$;
|
|
449
|
+
|
|
450
|
+
comment on function agent_board.signed_set_board_authority_ceiling(jsonb, jsonb) is
|
|
451
|
+
'#247 signed-write gateway wrapper for set_board_authority_ceiling. Resolves the actor with _signed_write_actor_id (gateway actor required; a differing payload p_actor_agent_id raises actor_mismatch 42501) and passes it as p_actor_agent_id, so the owner gate authorizes the SAME principal the evidence row names. Records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref. #269: between the ceiling upsert and the evidence row it calls _assert_signed_authority_signer_live, which requires the decision''s verification_status to be exactly ''verified'' and then share-locks and revalidates the signer''s agents row and the decision''s signing key inside this transaction; it sits after the inner call so the wrapper''s lock order stays advisory -> boards -> agents -> agent_signing_keys, matching onboard_federated_fleet_agent. Adds no authority: the owner gate still rejects any actor that is not an active board owner. service_role only, like every other signed_* wrapper (#118).';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.27.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 126,
|
|
50
|
+
"aggregateSha256": "7fbe4374b98783d1b88a075fd5b6c11723b40fccd6a4b4a7c7a80d3d1666ea2f",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1155,6 +1155,33 @@
|
|
|
1155
1155
|
"sourceRef": "message-board",
|
|
1156
1156
|
"bytes": 8152,
|
|
1157
1157
|
"sha256": "d5328a0006ecd1ec940c4fde5bbb43acf29b0997eefbea557009f792868e298b"
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
"order": 123,
|
|
1161
|
+
"id": "20260906000100_agent_board_enroll_resumption",
|
|
1162
|
+
"filename": "20260906000100_agent_board_enroll_resumption.sql",
|
|
1163
|
+
"origin": "message-board-forward",
|
|
1164
|
+
"sourceRef": "message-board",
|
|
1165
|
+
"bytes": 41571,
|
|
1166
|
+
"sha256": "2aafad9df5da006039268d9559311f85fba634b6ec44d06a2f55fe3deedb09d8"
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
"order": 124,
|
|
1170
|
+
"id": "20260907000100_agent_board_signed_authority_provenance",
|
|
1171
|
+
"filename": "20260907000100_agent_board_signed_authority_provenance.sql",
|
|
1172
|
+
"origin": "message-board-forward",
|
|
1173
|
+
"sourceRef": "message-board",
|
|
1174
|
+
"bytes": 12633,
|
|
1175
|
+
"sha256": "21111e289e0bb70ac3236f5422476c0c51d0bca5a815c11dd4e48b6932cf7c6b"
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
"order": 125,
|
|
1179
|
+
"id": "20260908000100_agent_board_signer_lifecycle_revalidation",
|
|
1180
|
+
"filename": "20260908000100_agent_board_signer_lifecycle_revalidation.sql",
|
|
1181
|
+
"origin": "message-board-forward",
|
|
1182
|
+
"sourceRef": "message-board",
|
|
1183
|
+
"bytes": 29336,
|
|
1184
|
+
"sha256": "bfb1ae60f69dae0a0aaf1007dbf14e171f324cdced1caf9f4d490f41a60c14e5"
|
|
1158
1185
|
}
|
|
1159
1186
|
],
|
|
1160
1187
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 =
|
|
41
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 126)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(126);
|
|
44
44
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
45
45
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
46
46
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -319,7 +319,7 @@ describe('plan / check postures + exit codes (SemVer contract)', () => {
|
|
|
319
319
|
expect(plan(portalHistory()).collisions).toHaveLength(1);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
it('a bare-version history is indeterminate, not
|
|
322
|
+
it('a bare-version history is indeterminate, not one collision per bundled migration', () => {
|
|
323
323
|
// normalizeHistory accepts plain version strings. Such an entry carries no
|
|
324
324
|
// name, so it cannot be told apart from the bundle's own migration at that
|
|
325
325
|
// version — reporting it as a collision turns a supported input shape into a
|