@consiliency/agent-board-schema 1.22.0 → 1.24.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/README.md +75 -1
- package/fixtures/already-applied.json +40 -0
- package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/check.js +23 -0
- package/src/index.js +20 -0
- package/src/plan.js +79 -5
- package/src/tooling.test.mjs +237 -4
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +46 -2
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
- package/tests/agent_board_inbox_reads.test.sql +16 -19
- package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
- package/tests/agent_board_roomlink.test.sql +2 -2
- package/tests/agent_board_wake_recovery.test.sql +756 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
-- Consiliency/message-board#193 -- enroll_federated_human skipped its board and
|
|
2
|
+
-- provider locking entirely when the provider row did not exist, then locked the
|
|
3
|
+
-- human owner and delegated. The delegate re-reads the provider under a FRESH READ
|
|
4
|
+
-- COMMITTED snapshot, so a provider inserted and committed in that gap was visible
|
|
5
|
+
-- to it and its default board was auto-joined while this transaction already held
|
|
6
|
+
-- auth.users:
|
|
7
|
+
--
|
|
8
|
+
-- portal fedsession (absent-provider path) holds owner -> wants board
|
|
9
|
+
-- onboard_federated_fleet_agent holds board -> wants owner
|
|
10
|
+
--
|
|
11
|
+
-- reproduced as `ERROR: deadlock detected`. This is the third instance of one
|
|
12
|
+
-- family (#153 machine<->domain, #189 ceiling-ABSENCE, this) plus #194: an
|
|
13
|
+
-- ordering guarantee that holds on the common path and escapes on an edge no
|
|
14
|
+
-- textual check can see. Here the escaping edge is ABSENCE -- there is no row to
|
|
15
|
+
-- lock, so the ordering simply does not happen.
|
|
16
|
+
--
|
|
17
|
+
-- THE FIX is to fail before acquiring anything, which is the only ordering-safe
|
|
18
|
+
-- answer when the thing to order on does not exist. The neighbouring case (a
|
|
19
|
+
-- concurrent NULL -> board designation) was already handled by 20260901000140 via
|
|
20
|
+
-- the snapshot/lock/compare; provider-absent-entirely was the case that escaped it.
|
|
21
|
+
--
|
|
22
|
+
-- BEHAVIOUR CHANGE IN THE RACE, written down rather than left to be discovered.
|
|
23
|
+
-- Pre-fix, a caller that lost a provider-creation race could OCCASIONALLY SUCCEED:
|
|
24
|
+
-- the delegate re-read the provider on a fresh READ COMMITTED snapshot and, if the
|
|
25
|
+
-- creation had committed in the gap, proceeded -- which is precisely the
|
|
26
|
+
-- interleaving that produced the deadlock. Post-fix that caller deterministically
|
|
27
|
+
-- fails ONCE, with the same 42501 a permanently unallowlisted issuer gets, and
|
|
28
|
+
-- succeeds on its next /portal-session call. That is the trade: an occasional
|
|
29
|
+
-- silent success bought with a cross-route cycle, exchanged for one deterministic
|
|
30
|
+
-- refusal.
|
|
31
|
+
--
|
|
32
|
+
-- WHY 42501 AND NOT A RETRYABLE 40001. No shipped caller distinguishes them.
|
|
33
|
+
-- Nothing under packages/*/src references 40001 or 40P01, and
|
|
34
|
+
-- AgentBoardClientError.retryable is set from HTTP status and only ever consumed by
|
|
35
|
+
-- the session-mint loop -- no code path maps a SQLSTATE to a retry. So the two
|
|
36
|
+
-- codes are operationally identical to every client that exists today, while 40001
|
|
37
|
+
-- would additionally instruct any FUTURE retry loop to spin forever on a genuinely
|
|
38
|
+
-- unallowlisted issuer. A serialization-based alternative (advisory-lock the
|
|
39
|
+
-- absence, re-read under it, let the race loser proceed) was built and reverted for
|
|
40
|
+
-- the same reason: it is machinery for a difference no caller can observe.
|
|
41
|
+
|
|
42
|
+
-- Recreated with CREATE OR REPLACE (ACL and COMMENT survive); grants and comment
|
|
43
|
+
-- restated below regardless, per the repo rule. Body is otherwise byte-identical to
|
|
44
|
+
-- 20260901000140.
|
|
45
|
+
--
|
|
46
|
+
-- WHAT THIS DOES NOT CHANGE. A permanently unallowlisted (issuer, audience) already
|
|
47
|
+
-- failed with 42501 -- from the delegate's step (4), after the owner had been
|
|
48
|
+
-- provisioned and locked. The verdict and the SQLSTATE are unchanged; only the
|
|
49
|
+
-- point at which they are reached moves, and with it the lock the transaction was
|
|
50
|
+
-- holding when it happened. tests/isolation/specs/fedsession_provider_absent_owner_lock.spec
|
|
51
|
+
-- pins exactly that: with a guard session holding the owner row, the pre-fix
|
|
52
|
+
-- function BLOCKS and the fixed one raises without waiting.
|
|
53
|
+
|
|
54
|
+
create or replace function agent_board.enroll_federated_human(
|
|
55
|
+
p_issuer text,
|
|
56
|
+
p_audience text,
|
|
57
|
+
p_subject text,
|
|
58
|
+
p_human_identity_claim jsonb,
|
|
59
|
+
p_display_name text default null
|
|
60
|
+
)
|
|
61
|
+
returns table(
|
|
62
|
+
agent_id uuid,
|
|
63
|
+
auth_user_id uuid,
|
|
64
|
+
handle text,
|
|
65
|
+
kind text,
|
|
66
|
+
status text,
|
|
67
|
+
provider_id text,
|
|
68
|
+
created_at timestamptz
|
|
69
|
+
)
|
|
70
|
+
language plpgsql
|
|
71
|
+
security definer
|
|
72
|
+
set search_path = agent_board, public
|
|
73
|
+
as $$
|
|
74
|
+
declare
|
|
75
|
+
v_provider_id agent_board.identity_providers.id%type;
|
|
76
|
+
v_default_board_id uuid;
|
|
77
|
+
v_locked_default_board_id uuid;
|
|
78
|
+
v_handle text;
|
|
79
|
+
v_uid uuid;
|
|
80
|
+
begin
|
|
81
|
+
if not agent_board._is_service_role() then
|
|
82
|
+
raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
|
|
83
|
+
end if;
|
|
84
|
+
if p_subject is null then
|
|
85
|
+
raise exception 'verified subject is required' using errcode = '22023';
|
|
86
|
+
end if;
|
|
87
|
+
|
|
88
|
+
-- Snapshot the provider's auto-join target without a row lock so a non-null
|
|
89
|
+
-- target can be locked in canonical board -> provider order. Then lock the
|
|
90
|
+
-- provider row and require that the target did not change across the gap. A
|
|
91
|
+
-- concurrent NULL -> board designation is rejected for retry rather than
|
|
92
|
+
-- falling back to provider -> board and recreating the cross-route cycle.
|
|
93
|
+
select ip.id, ip.default_board_id
|
|
94
|
+
into v_provider_id, v_default_board_id
|
|
95
|
+
from agent_board.identity_providers ip
|
|
96
|
+
where ip.issuer = p_issuer
|
|
97
|
+
and ip.audience = p_audience
|
|
98
|
+
limit 1;
|
|
99
|
+
|
|
100
|
+
-- #193 PROVIDER-ABSENCE GATE, and it must come BEFORE the owner is provisioned
|
|
101
|
+
-- or locked. PostgreSQL cannot row-lock a row that does not exist, so an absent
|
|
102
|
+
-- provider left NOTHING to serialize on: this function used to skip the whole
|
|
103
|
+
-- board/provider ordering, lock the owner, and delegate -- and under READ
|
|
104
|
+
-- COMMITTED the delegate takes a FRESH snapshot, so a provider committed in that
|
|
105
|
+
-- gap became visible and its default board was auto-joined while this
|
|
106
|
+
-- transaction held auth.users. That is owner -> board, inverted against fleet
|
|
107
|
+
-- onboarding's board -> owner, and it deadlocked.
|
|
108
|
+
--
|
|
109
|
+
-- Rejecting here is a VALID serialization -- it orders this call before the
|
|
110
|
+
-- provider's creation, which is exactly what its own snapshot saw -- and it is
|
|
111
|
+
-- the same verdict _enroll_federated_human_after_revocation_gate step (4) would
|
|
112
|
+
-- have reached anyway for an allowlist miss, only now reached before any lock.
|
|
113
|
+
-- The message differs from the delegate's so a test can tell which gate fired.
|
|
114
|
+
-- NOTE: this is a permanent 42501, not a retryable 40001. A genuinely
|
|
115
|
+
-- unallowlisted issuer is a configuration error, and telling a caller to retry it
|
|
116
|
+
-- forever is worse than telling it no; the rare loser of a real provider-creation
|
|
117
|
+
-- race succeeds on its next /portal-session call.
|
|
118
|
+
if v_provider_id is null then
|
|
119
|
+
raise exception 'issuer/audience not in the identity-provider allowlist (rejected before any owner or board lock)'
|
|
120
|
+
using errcode = '42501';
|
|
121
|
+
end if;
|
|
122
|
+
|
|
123
|
+
if v_default_board_id is not null then
|
|
124
|
+
perform 1 from agent_board.boards b
|
|
125
|
+
where b.id = v_default_board_id
|
|
126
|
+
for update;
|
|
127
|
+
if not found then
|
|
128
|
+
raise exception 'federated human default board changed during enrollment'
|
|
129
|
+
using errcode = '40001';
|
|
130
|
+
end if;
|
|
131
|
+
end if;
|
|
132
|
+
|
|
133
|
+
-- Freeze the designation this transaction will delegate against. The provider row
|
|
134
|
+
-- is held FOR UPDATE through commit, and (issuer, audience) is UNIQUE, so the
|
|
135
|
+
-- delegate's own lookup cannot resolve to a different row or a changed
|
|
136
|
+
-- default_board_id than the one locked above.
|
|
137
|
+
select ip.default_board_id
|
|
138
|
+
into v_locked_default_board_id
|
|
139
|
+
from agent_board.identity_providers ip
|
|
140
|
+
where ip.id = v_provider_id
|
|
141
|
+
for update;
|
|
142
|
+
if not found or v_locked_default_board_id is distinct from v_default_board_id then
|
|
143
|
+
raise exception 'federated human default board changed during enrollment'
|
|
144
|
+
using errcode = '40001';
|
|
145
|
+
end if;
|
|
146
|
+
|
|
147
|
+
v_handle := 'fed-human-' || substr(
|
|
148
|
+
encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
|
|
149
|
+
1, 32);
|
|
150
|
+
v_uid := agent_board._ensure_agent_auth_user(v_handle);
|
|
151
|
+
|
|
152
|
+
perform 1 from auth.users u where u.id = v_uid for update;
|
|
153
|
+
if not found then
|
|
154
|
+
raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
|
|
155
|
+
end if;
|
|
156
|
+
if exists (
|
|
157
|
+
select 1
|
|
158
|
+
from agent_board.fleet_human_revocations r
|
|
159
|
+
where r.issuer = p_issuer
|
|
160
|
+
and r.subject = p_subject
|
|
161
|
+
) then
|
|
162
|
+
raise exception 'human identity is durably revoked' using errcode = '42501';
|
|
163
|
+
end if;
|
|
164
|
+
|
|
165
|
+
return query
|
|
166
|
+
select * from agent_board._enroll_federated_human_after_revocation_gate(
|
|
167
|
+
p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
|
|
168
|
+
);
|
|
169
|
+
end;
|
|
170
|
+
$$;
|
|
171
|
+
|
|
172
|
+
comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
|
|
173
|
+
'Service-role federated-human enrollment used by /portal-session. Requires an allowlisted (issuer, audience) BEFORE provisioning or locking anything (#193: an absent provider cannot be row-locked, so proceeding left the board/provider ordering unenforced and inverted board -> owner against fleet onboarding), then locks a non-null default board before its provider row, freezes that designation, holds the deterministic auth.users owner lock, rejects durable issuer/subject revocation, and delegates to the internal provisioner.';
|
|
174
|
+
revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
|
|
175
|
+
from public, anon, authenticated;
|
|
176
|
+
grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
|
|
177
|
+
to service_role;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
-- WAKERECOVERY / message-board#191 — a gap-safe wake recovery surface.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT THIS CLOSES. A wake consumer that gates recovery on a monotonic
|
|
4
|
+
-- cursor silently drops a late-committing writer. `callback_outbox_events.seq`
|
|
5
|
+
-- (#187) is an identity column and identity sequences are NON-TRANSACTIONAL, so
|
|
6
|
+
-- `seq` is monotonic in ASSIGNMENT order, not COMMIT order: a transaction that
|
|
7
|
+
-- inserts early and commits late holds a seq BELOW a cursor the consumer has
|
|
8
|
+
-- already persisted, and `seq > my_cursor` never returns it. The committed
|
|
9
|
+
-- isolation probe `outbox_wake_ordering.spec` records exactly that
|
|
10
|
+
-- (`late_committer_holds_a_lower_seq_than_the_persisted_cursor = t`).
|
|
11
|
+
--
|
|
12
|
+
-- THE CLASS IS WIDER THAN `seq`. `created_at` / `updated_at` default to `now()`,
|
|
13
|
+
-- which is `transaction_timestamp()` — also assigned before commit, and worse:
|
|
14
|
+
-- a fan-out writes N rows with byte-identical timestamps, so a timestamp cursor
|
|
15
|
+
-- cannot even totally order what it DOES see. The unsafe class is therefore
|
|
16
|
+
-- "gates recovery on ANY value assigned before commit", not "gates on seq".
|
|
17
|
+
--
|
|
18
|
+
-- THE FIX IS TO STOP ASKING AN ORDERING QUESTION. Restart recovery does not need
|
|
19
|
+
-- "what is new since I last looked" (a cursor); it needs "what have I not dealt
|
|
20
|
+
-- with" (a STATE). A late-committing insert lands with `action_state = 'new'`, so
|
|
21
|
+
-- the moment it is visible it is in the set. There is no value to compare and
|
|
22
|
+
-- therefore nothing to skip. This is the same property that makes
|
|
23
|
+
-- `claim_callback_outbox_events` lossless (#187: "the claim path is lossless
|
|
24
|
+
-- because it partitions on STATUS, never on seq"), applied to the surface a wake
|
|
25
|
+
-- consumer actually fetches from.
|
|
26
|
+
--
|
|
27
|
+
-- WHY A VIEW AND NOT A FILTER IN THE CONSUMER. A predicate that lives only in a
|
|
28
|
+
-- TypeScript descriptor cannot be pinned: an isolation spec could only assert a
|
|
29
|
+
-- query the spec itself writes, and mutating the spec to make the spec fail
|
|
30
|
+
-- proves nothing. A view puts the predicate in pg_catalog where a probe can read
|
|
31
|
+
-- it and a mutation can break it. `pg_get_viewdef` is regenerated from the parse
|
|
32
|
+
-- tree, so comments and formatting are gone by construction — a class assertion
|
|
33
|
+
-- over a view definition cannot be defeated by prose the way a `prosrc` scanner
|
|
34
|
+
-- in this repo was once defeated by a COMMENT placed beside an inverted lock.
|
|
35
|
+
--
|
|
36
|
+
-- WHAT THIS MIGRATION DOES NOT DO. No producer changes. `inbox_items` gains no
|
|
37
|
+
-- column, no trigger, no policy. `callback_outbox_events` and its `seq` are
|
|
38
|
+
-- untouched — `seq` keeps exactly the meaning #187 shipped it with (deterministic
|
|
39
|
+
-- claim ordering and observability, never a recovery cursor).
|
|
40
|
+
-- `agent_board.schema_version` stays at 8: this adds no enforcement switch.
|
|
41
|
+
--
|
|
42
|
+
-- RECIPIENT SCOPING IS IN THIS VIEW, NOT IN THE CONSUMER'S FILTER. RLS on
|
|
43
|
+
-- `inbox_items` is NOT self-scoped: `agent_board_authenticated_select_inbox_items`
|
|
44
|
+
-- (20260713000000) admits a second disjunct --
|
|
45
|
+
-- board_id is not null AND visible_in_board_feed AND caller is a co-member
|
|
46
|
+
-- -- and `post_board_message` upserts every board delivery with BOTH visibility
|
|
47
|
+
-- flags true. So an unfiltered read of the unhandled set hands a caller its
|
|
48
|
+
-- CO-MEMBERS' items, which a triage consumer would then act on. Measured, not
|
|
49
|
+
-- reasoned: driving the real `post_board_message` path and reading this view as
|
|
50
|
+
-- agent A returned 1 row belonging to agent B before this predicate was added.
|
|
51
|
+
--
|
|
52
|
+
-- The scoping deliberately lives HERE and not in the runtime's fetch descriptor.
|
|
53
|
+
-- The FLEETNOTIFY contract states that a hint's filters are never authority; a
|
|
54
|
+
-- security boundary placed in `filters` is one a caller can drop or mistype, and
|
|
55
|
+
-- no catalog assertion could pin it. In the view it is server-side, applies to
|
|
56
|
+
-- every caller regardless of the descriptor, and is pinnable from pg_catalog --
|
|
57
|
+
-- the same argument that made this a view rather than a TypeScript predicate.
|
|
58
|
+
--
|
|
59
|
+
-- ACL NOTE. There is no DROP FUNCTION here, so the ACL/COMMENT-discard trap does
|
|
60
|
+
-- not arise. A `create or replace view` preserves an existing ACL, so the revoke
|
|
61
|
+
-- below is stated explicitly rather than implied by omitting a grant: re-running
|
|
62
|
+
-- this migration over an earlier copy of the view must CONVERGE, not inherit.
|
|
63
|
+
--
|
|
64
|
+
-- `service_role` is deliberately DENIED, which is why the grants are NOT a mirror
|
|
65
|
+
-- of `v_agent_inbox`'s. `service_role` holds BYPASSRLS and `current_agent_id()`
|
|
66
|
+
-- returns NULL for it, so a scoped view would hand it a silently EMPTY recovery
|
|
67
|
+
-- set -- an identity-less principal being told "nothing to recover" is precisely
|
|
68
|
+
-- the silent-drop failure this migration exists to close. Denying SELECT converts
|
|
69
|
+
-- that silence into a loud `permission denied`. This surface is defined relative
|
|
70
|
+
-- to the session's own agent identity; a principal without one has no answer here,
|
|
71
|
+
-- and must read `v_agent_inbox` with an explicit recipient filter instead.
|
|
72
|
+
|
|
73
|
+
-- Built on `v_agent_inbox`, not on `inbox_items`, so `visible_in_inbox = true` is
|
|
74
|
+
-- INHERITED rather than restated: one rule, one place.
|
|
75
|
+
--
|
|
76
|
+
-- WHAT `security_invoker = true` BUYS -- corrected AGAIN, from a full matrix.
|
|
77
|
+
-- Cross-agent exclusion is enforced by the `current_agent_id()` predicate below,
|
|
78
|
+
-- not by RLS. With that predicate deleted and the read taken as agent A, the four
|
|
79
|
+
-- cells are (co-member rows / stranger rows returned):
|
|
80
|
+
--
|
|
81
|
+
-- this view v_agent_inbox co-member stranger
|
|
82
|
+
-- invoker invoker 1 0
|
|
83
|
+
-- reset invoker 1 0
|
|
84
|
+
-- invoker reset 1 1
|
|
85
|
+
-- reset reset 1 1
|
|
86
|
+
--
|
|
87
|
+
-- THIS view's flag is inert in every cell. The bound is carried entirely by
|
|
88
|
+
-- `v_agent_inbox`'s flag: an inner `security_invoker` view re-anchors base-table
|
|
89
|
+
-- RLS to the real querying user through any outer definer view. An earlier
|
|
90
|
+
-- version of this paragraph said the bound held "on this view OR on
|
|
91
|
+
-- `v_agent_inbox`" -- attributing it to a flag that does not carry it. The flag
|
|
92
|
+
-- is still declared here because it becomes load-bearing the moment this view is
|
|
93
|
+
-- rebuilt directly on `inbox_items`, and `v_agent_inbox`'s flag is pinned as a
|
|
94
|
+
-- catalog-derived class over every agent_board view in
|
|
95
|
+
-- agent_board_inbox_reads.test.sql.
|
|
96
|
+
--
|
|
97
|
+
-- WHAT RECOVERY COMPLETENESS NOW DEPENDS ON, stated because this migration is what
|
|
98
|
+
-- makes it depend on them. `current_agent_id()` resolves `auth.uid()` to one
|
|
99
|
+
-- active agent. It carries no ORDER BY, but that is NOT a hazard: partial unique
|
|
100
|
+
-- indexes `idx_agent_board_agents_auth_user_id_unique` (auth_user_id IS NOT NULL)
|
|
101
|
+
-- and `..._active_unique` make two agents on one auth user impossible, so the
|
|
102
|
+
-- resolution is single-valued. (Checked -- a review seat reported "no unique
|
|
103
|
+
-- constraint on agents.auth_user_id", which is true of pg_constraint and false of
|
|
104
|
+
-- the schema: the guarantee is enforced by unique INDEXES.)
|
|
105
|
+
--
|
|
106
|
+
-- The real exposure is the other one: if the caller has NO active agent -- revoked,
|
|
107
|
+
-- or an operator session with no agent identity -- `current_agent_id()` returns
|
|
108
|
+
-- NULL and this view returns an EMPTY set rather than raising. Empty is
|
|
109
|
+
-- indistinguishable from "nothing to recover", which is the same silent-drop shape
|
|
110
|
+
-- the `service_role` revoke below closes, one layer up. A consumer that can be
|
|
111
|
+
-- identity-less must check its identity, not infer it from an empty recovery set.
|
|
112
|
+
--
|
|
113
|
+
-- There is deliberately NO `ORDER BY`. The unhandled set is a SET, not a cursor
|
|
114
|
+
-- stream. Ordering is presentation and belongs to the caller; putting one here
|
|
115
|
+
-- would invite keyset pagination over `created_at`, which is the exact defect
|
|
116
|
+
-- this migration closes.
|
|
117
|
+
--
|
|
118
|
+
-- The state set is `('new', 'restored', 'deferred')` VERBATIM from the existing
|
|
119
|
+
-- partial index `idx_agent_board_inbox_items_agent_pending`, so the index serves
|
|
120
|
+
-- this view. The pgTAP asserts that equality from the catalog on BOTH sides, so
|
|
121
|
+
-- editing either one alone fails.
|
|
122
|
+
create or replace view agent_board.v_agent_wake_recovery
|
|
123
|
+
with (security_invoker = true)
|
|
124
|
+
as
|
|
125
|
+
select
|
|
126
|
+
vi.inbox_item_id,
|
|
127
|
+
vi.delivery_recipient_id,
|
|
128
|
+
vi.recipient_agent_id,
|
|
129
|
+
vi.recipient_handle,
|
|
130
|
+
vi.recipient_display_name,
|
|
131
|
+
vi.board_id,
|
|
132
|
+
vi.board_slug,
|
|
133
|
+
vi.board_name,
|
|
134
|
+
vi.delivery_mode,
|
|
135
|
+
vi.source_kind,
|
|
136
|
+
vi.source_message_id,
|
|
137
|
+
vi.source_task_id,
|
|
138
|
+
vi.source_delegation_id,
|
|
139
|
+
vi.subject,
|
|
140
|
+
vi.preview,
|
|
141
|
+
vi.priority,
|
|
142
|
+
vi.action_state,
|
|
143
|
+
vi.visible_in_board_feed,
|
|
144
|
+
vi.fetch_descriptor,
|
|
145
|
+
vi.created_at,
|
|
146
|
+
vi.updated_at
|
|
147
|
+
from agent_board.v_agent_inbox vi
|
|
148
|
+
where vi.action_state in ('new', 'restored', 'deferred')
|
|
149
|
+
and vi.recipient_agent_id = agent_board.current_agent_id();
|
|
150
|
+
|
|
151
|
+
grant select on agent_board.v_agent_wake_recovery to authenticated;
|
|
152
|
+
revoke all on agent_board.v_agent_wake_recovery from service_role;
|
|
153
|
+
|
|
154
|
+
comment on view agent_board.v_agent_wake_recovery is
|
|
155
|
+
'message-board#191 gap-safe wake recovery surface: the caller''s UNHANDLED inbox set (action_state in new/restored/deferred), partitioned on STATUS and carrying no cursor. Recovery must never gate on a pre-commit-assigned value (callback_outbox_events.seq, created_at, updated_at): those order ASSIGNMENT, not COMMIT, so a transaction that inserts early and commits late lands below an already-persisted cursor and is silently dropped. Scoped to the calling agent (current_agent_id()): RLS on inbox_items is NOT self-scoped -- it also admits co-members'' board-feed-visible items -- so the scoping is stated here rather than left to a caller''s filter. EXIT PATHS, verified against pg_catalog and not against the design doc: agent_board.update_inbox_item_state is the ONLY function that writes inbox_items.action_state. mark_read does NOT -- it writes read_receipts, so a read item is still unhandled. notification_ack is not a database function at all; it is a client-side alias that calls update_inbox_item_state with action_state = ''acknowledged''. DRAIN IN BOUNDED PASSES, NEVER "UNTIL EMPTY": ''deferred'' is itself an explicit ack write AND is a member of this set, so deferring an item acknowledges it and leaves it here -- a consumer that defers anything and loops until empty never terminates. Concurrent inserts can also outpace processing. A drain MUST bound its own passes and must not treat a non-empty set as a reason to keep looping. WHY THE ONE-WRITER CLAIM IS STRUCTURAL: inbox_items carries no user trigger, and authenticated/service_role hold SELECT only (no direct UPDATE), so nothing can move an item out of this set except the definer RPC. The second DML writer, _upsert_inbox_item, never touches action_state on conflict -- though it does overwrite visible_in_inbox, which would hide an item from this set with no ack if a caller ever passed false (no current caller does; the argument is positional and this is not pinned). security_invoker, so RLS on inbox_items still applies underneath the scoping.';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
-- Reconcile join_board onto its consent-gated definition (message-board#217).
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS EXISTS. Supabase keys supabase_migrations.schema_migrations on the
|
|
4
|
+
-- 14-digit VERSION PREFIX alone, not the name. An adopter that independently
|
|
5
|
+
-- authored a migration at 20260809000100 therefore has that version recorded
|
|
6
|
+
-- under its own name, and the bundle's 20260809000100_agent_board_consent_gating
|
|
7
|
+
-- is SKIPPED SILENTLY -- no error, because that file is three
|
|
8
|
+
-- `create or replace function` statements. The database reaches schema_version 8
|
|
9
|
+
-- while retaining pre-consent authorization semantics, and the deploy reports
|
|
10
|
+
-- success. Found by Consiliency/consiliency-portal while adopting 1.22.0.
|
|
11
|
+
--
|
|
12
|
+
-- WHY join_board AND NOTHING ELSE. The skipped file redefines three functions,
|
|
13
|
+
-- but two of them are superseded by LATER migrations that carry the consent
|
|
14
|
+
-- gating forward: resolve_board_request by 20260819000100 and
|
|
15
|
+
-- update_board_membership by 20260813000100. join_board has no later definition
|
|
16
|
+
-- -- its newest is the skipped one -- so on an affected database it is still the
|
|
17
|
+
-- pre-consent 20260803000100 body. That single function is the entire live gap.
|
|
18
|
+
--
|
|
19
|
+
-- WHY NOT RENUMBER THE ORIGINAL. Renumbering looks safe because the migration is
|
|
20
|
+
-- idempotent, but idempotent is not order-independent. On a HEALTHY database the
|
|
21
|
+
-- renumbered file would be the only unapplied migration, so it would run LAST --
|
|
22
|
+
-- after 20260813000100 and 20260819000100 -- and re-apply the older
|
|
23
|
+
-- resolve_board_request and update_board_membership bodies over the newer ones.
|
|
24
|
+
-- That is a silent authorization ROLLBACK on every correctly-adopted database,
|
|
25
|
+
-- including production. A forward-only migration cannot do that, because it runs
|
|
26
|
+
-- last by construction and carries the newest definition of the only function it
|
|
27
|
+
-- touches.
|
|
28
|
+
--
|
|
29
|
+
-- EFFECT. Healthy database: byte-identical body, a true no-op. Affected database:
|
|
30
|
+
-- receives the consent gating it never got, with no `supabase migration repair`
|
|
31
|
+
-- and no operator intervention. `create or replace` preserves the existing ACL
|
|
32
|
+
-- and this migration adds no new grants, so the privilege surface is unchanged
|
|
33
|
+
-- either way.
|
|
34
|
+
|
|
35
|
+
create or replace function agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
36
|
+
RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
37
|
+
LANGUAGE plpgsql
|
|
38
|
+
SECURITY DEFINER
|
|
39
|
+
SET search_path TO 'agent_board', 'public'
|
|
40
|
+
as $$
|
|
41
|
+
declare
|
|
42
|
+
v_board agent_board.boards%rowtype;
|
|
43
|
+
v_existing_state text;
|
|
44
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
45
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
46
|
+
begin
|
|
47
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
48
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
49
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
50
|
+
end if;
|
|
51
|
+
|
|
52
|
+
if p_membership_state not in ('joined', 'subscribed') then
|
|
53
|
+
raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
|
|
54
|
+
end if;
|
|
55
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
56
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
57
|
+
end if;
|
|
58
|
+
if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
59
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
60
|
+
end if;
|
|
61
|
+
|
|
62
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
63
|
+
if v_board.id is null then
|
|
64
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
|
|
68
|
+
-- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
|
|
69
|
+
-- locking an active member out of their own private / invite_only board (P0002).
|
|
70
|
+
select bm.membership_state
|
|
71
|
+
into v_existing_state
|
|
72
|
+
from agent_board.board_memberships bm
|
|
73
|
+
where bm.board_id = p_board_id
|
|
74
|
+
and bm.agent_id = p_agent_id
|
|
75
|
+
order by
|
|
76
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
77
|
+
bm.created_at desc,
|
|
78
|
+
bm.id desc
|
|
79
|
+
limit 1;
|
|
80
|
+
|
|
81
|
+
-- #82: the CREATOR EXEMPTION IS REMOVED. Previously a board's creator could join ANY agent
|
|
82
|
+
-- to their board -- `v_actor <> v_board.created_by_agent_id` let them past this guard, and
|
|
83
|
+
-- the joinability check below only runs for self-joins, so nothing else stopped them. That
|
|
84
|
+
-- is the force-join hole. Creating a board does not entitle you to place other people in it.
|
|
85
|
+
--
|
|
86
|
+
-- Only the subject may join the subject. service_role (v_actor null) keeps the operator
|
|
87
|
+
-- carve-out, which is deliberate and maintainer-confirmed.
|
|
88
|
+
if v_actor is not null and v_actor <> p_agent_id then
|
|
89
|
+
raise exception 'board membership actor mismatch' using errcode = '42501';
|
|
90
|
+
end if;
|
|
91
|
+
|
|
92
|
+
-- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
|
|
93
|
+
-- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
|
|
94
|
+
-- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
|
|
95
|
+
-- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
|
|
96
|
+
-- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
|
|
97
|
+
-- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
|
|
98
|
+
--
|
|
99
|
+
-- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
|
|
100
|
+
-- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
|
|
101
|
+
-- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
|
|
102
|
+
-- created_at can surface 'left' as the total-order winner while standing is still removed
|
|
103
|
+
-- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
|
|
104
|
+
if v_actor is not null
|
|
105
|
+
and v_actor = p_agent_id
|
|
106
|
+
and v_board.created_by_agent_id <> v_actor
|
|
107
|
+
and not (
|
|
108
|
+
not (
|
|
109
|
+
exists (
|
|
110
|
+
select 1 from agent_board.board_memberships bm
|
|
111
|
+
where bm.board_id = p_board_id
|
|
112
|
+
and bm.agent_id = p_agent_id
|
|
113
|
+
and bm.membership_state = 'removed'
|
|
114
|
+
) and not exists (
|
|
115
|
+
select 1 from agent_board.board_memberships bm
|
|
116
|
+
where bm.board_id = p_board_id
|
|
117
|
+
and bm.agent_id = p_agent_id
|
|
118
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
and (
|
|
122
|
+
v_board.visibility = 'open'
|
|
123
|
+
-- #108: HOLDING AN INVITATION ADMITS THE SUBJECT ON ANY VISIBILITY, not only
|
|
124
|
+
-- invite_only. Previously `private` and `directory_hidden` were excluded outright, so
|
|
125
|
+
-- an invited subject had NO act available -- verified: a first-time invitee with no
|
|
126
|
+
-- prior refusal was blocked identically to a refused one. Gating makes consent
|
|
127
|
+
-- REQUIRED, so a board type where consent cannot be GIVEN is correct-but-unusable.
|
|
128
|
+
--
|
|
129
|
+
-- This is the narrowest widening that makes consent exercisable. It admits nobody new:
|
|
130
|
+
-- this whole guard is reached only when v_actor = p_agent_id (the actor-mismatch raise
|
|
131
|
+
-- above returns otherwise), so it is self-accept only. The board still decides who is
|
|
132
|
+
-- invited; the subject decides whether to accept.
|
|
133
|
+
or coalesce(v_existing_state, '') = 'invited'
|
|
134
|
+
or coalesce(v_existing_state, '') in ('joined', 'subscribed')
|
|
135
|
+
)
|
|
136
|
+
) then
|
|
137
|
+
raise exception 'board is not joinable by this agent' using errcode = 'P0002';
|
|
138
|
+
end if;
|
|
139
|
+
|
|
140
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
141
|
+
p_board_id,
|
|
142
|
+
p_agent_id,
|
|
143
|
+
p_membership_state,
|
|
144
|
+
coalesce(p_created_by_agent_id, p_agent_id),
|
|
145
|
+
p_metadata
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return query
|
|
149
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
150
|
+
end;
|
|
151
|
+
$$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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.24.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": 123,
|
|
50
|
+
"aggregateSha256": "f268991d3da80351c547d94249c48a5069a66267c3c1eaf580193bb47a89ff74",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1065,6 +1065,96 @@
|
|
|
1065
1065
|
"sourceRef": "message-board",
|
|
1066
1066
|
"bytes": 25051,
|
|
1067
1067
|
"sha256": "8d24ec4d682969a5d9339aec0573fad65ee95993dce2f8dca214906549b5ebe8"
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
"order": 113,
|
|
1071
|
+
"id": "20260901000100_agent_board_atomic_fleet_onboarding",
|
|
1072
|
+
"filename": "20260901000100_agent_board_atomic_fleet_onboarding.sql",
|
|
1073
|
+
"origin": "message-board-forward",
|
|
1074
|
+
"sourceRef": "message-board",
|
|
1075
|
+
"bytes": 64896,
|
|
1076
|
+
"sha256": "4cf07673b7c7da4ee13550e71d2bfe606b4b9c56971169e1ef61ac0d545ac6ef"
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
"order": 114,
|
|
1080
|
+
"id": "20260901000110_agent_board_fleet_onboarding_ceiling_lock",
|
|
1081
|
+
"filename": "20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql",
|
|
1082
|
+
"origin": "message-board-forward",
|
|
1083
|
+
"sourceRef": "message-board",
|
|
1084
|
+
"bytes": 30096,
|
|
1085
|
+
"sha256": "310a32931562be6b4ad323b5e2926ebe459d6cd31bd6f601c7b862f354b5ce58"
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
"order": 115,
|
|
1089
|
+
"id": "20260901000120_agent_board_human_revocation_evidence_completeness",
|
|
1090
|
+
"filename": "20260901000120_agent_board_human_revocation_evidence_completeness.sql",
|
|
1091
|
+
"origin": "message-board-forward",
|
|
1092
|
+
"sourceRef": "message-board",
|
|
1093
|
+
"bytes": 3077,
|
|
1094
|
+
"sha256": "e8ccb3605fb6dfa4e8dfbefbbb4328179a61aec00616c1abef27d6a09c2a913e"
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
"order": 116,
|
|
1098
|
+
"id": "20260901000130_agent_board_fedsession_human_revocation_gate",
|
|
1099
|
+
"filename": "20260901000130_agent_board_fedsession_human_revocation_gate.sql",
|
|
1100
|
+
"origin": "message-board-forward",
|
|
1101
|
+
"sourceRef": "message-board",
|
|
1102
|
+
"bytes": 2919,
|
|
1103
|
+
"sha256": "f76d78973b87cc66c54ca0af4754358221ede19c8e0644df81a08e65dab0e89b"
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
"order": 117,
|
|
1107
|
+
"id": "20260901000140_agent_board_fedsession_board_owner_lock_order",
|
|
1108
|
+
"filename": "20260901000140_agent_board_fedsession_board_owner_lock_order.sql",
|
|
1109
|
+
"origin": "message-board-forward",
|
|
1110
|
+
"sourceRef": "message-board",
|
|
1111
|
+
"bytes": 3824,
|
|
1112
|
+
"sha256": "2388528532e974ab27c35337f9b00263056c1b16d973b7b3a0cf8cebb7724cc4"
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
"order": 118,
|
|
1116
|
+
"id": "20260902000100_agent_board_outbox_wake_ordering",
|
|
1117
|
+
"filename": "20260902000100_agent_board_outbox_wake_ordering.sql",
|
|
1118
|
+
"origin": "message-board-forward",
|
|
1119
|
+
"sourceRef": "message-board",
|
|
1120
|
+
"bytes": 8783,
|
|
1121
|
+
"sha256": "d3557acc29bf146862920fe903c3ac2e9e0e0317f3ca2134c5bb0835620fd2da"
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
"order": 119,
|
|
1125
|
+
"id": "20260903000100_agent_board_enroll_identity_board_owner_lock_order",
|
|
1126
|
+
"filename": "20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql",
|
|
1127
|
+
"origin": "message-board-forward",
|
|
1128
|
+
"sourceRef": "message-board",
|
|
1129
|
+
"bytes": 29382,
|
|
1130
|
+
"sha256": "f8dc4ae6c5c6ea56183258402ab283cd424929b8e637984a59cf6ecd0685f681"
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
"order": 120,
|
|
1134
|
+
"id": "20260903000200_agent_board_fedsession_provider_absence_gate",
|
|
1135
|
+
"filename": "20260903000200_agent_board_fedsession_provider_absence_gate.sql",
|
|
1136
|
+
"origin": "message-board-forward",
|
|
1137
|
+
"sourceRef": "message-board",
|
|
1138
|
+
"bytes": 8967,
|
|
1139
|
+
"sha256": "79c581011be386d0efd97db723d3aa07947ad7b996667878ed1387cdd106b000"
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
"order": 121,
|
|
1143
|
+
"id": "20260904000100_agent_board_wake_recovery_surface",
|
|
1144
|
+
"filename": "20260904000100_agent_board_wake_recovery_surface.sql",
|
|
1145
|
+
"origin": "message-board-forward",
|
|
1146
|
+
"sourceRef": "message-board",
|
|
1147
|
+
"bytes": 10795,
|
|
1148
|
+
"sha256": "6349f705751104c5d60ec6602ea534e7c8b7de5c4c6ad2d84dbda680a5267848"
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
"order": 122,
|
|
1152
|
+
"id": "20260905000100_agent_board_consent_gating_reconcile",
|
|
1153
|
+
"filename": "20260905000100_agent_board_consent_gating_reconcile.sql",
|
|
1154
|
+
"origin": "message-board-forward",
|
|
1155
|
+
"sourceRef": "message-board",
|
|
1156
|
+
"bytes": 8152,
|
|
1157
|
+
"sha256": "d5328a0006ecd1ec940c4fde5bbb43acf29b0997eefbea557009f792868e298b"
|
|
1068
1158
|
}
|
|
1069
1159
|
],
|
|
1070
1160
|
"excludedBridge": {
|
package/src/check.js
CHANGED
|
@@ -9,8 +9,29 @@ export const EXIT = {
|
|
|
9
9
|
ahead: 11,
|
|
10
10
|
divergent: 12,
|
|
11
11
|
error: 13,
|
|
12
|
+
// 14, not a reuse of `behind`: a collision previously reported as behind, and
|
|
13
|
+
// "behind" invites the fix of applying again -- which is exactly the no-op
|
|
14
|
+
// that leaves the database wrong. See message-board#217.
|
|
15
|
+
collision: 14,
|
|
12
16
|
};
|
|
13
17
|
|
|
18
|
+
// Operator guidance per posture. It lives HERE, next to the posture definition,
|
|
19
|
+
// because the live-drift tool selected its message with a ternary over two named
|
|
20
|
+
// postures and an else -- so a `collision` printed "the deployment has migrations
|
|
21
|
+
// the bundle does not know about", which is the exact misdiagnosis this posture
|
|
22
|
+
// exists to prevent. A completeness test over EXIT keeps a new posture from ever
|
|
23
|
+
// again arriving without an action.
|
|
24
|
+
export const POSTURE_ACTIONS = Object.freeze({
|
|
25
|
+
exact: null, // nothing to do
|
|
26
|
+
behind:
|
|
27
|
+
'migrations in the bundle are NOT applied to this deployment (or were applied without being recorded). Reconcile before assuming the board is current.',
|
|
28
|
+
ahead: 'the deployment has migrations the bundle does not know about.',
|
|
29
|
+
divergent:
|
|
30
|
+
'an applied migration digest does NOT match the bundle — the deployment ran different bytes. Investigate before applying anything further.',
|
|
31
|
+
collision:
|
|
32
|
+
'a bundle migration was SKIPPED because its version is already recorded under a different name. The deployment is missing that DDL while reporting the bundle\'s schema version. Do NOT simply re-apply: the version is taken, so applying again is a no-op. See the collisions list for the expected and recorded ids, and message-board#217.',
|
|
33
|
+
});
|
|
34
|
+
|
|
14
35
|
export function check(history) {
|
|
15
36
|
const p = plan(history);
|
|
16
37
|
return {
|
|
@@ -19,5 +40,7 @@ export function check(history) {
|
|
|
19
40
|
exitCode: EXIT[p.posture] ?? EXIT.error,
|
|
20
41
|
counts: p.counts,
|
|
21
42
|
ahead: p.ahead,
|
|
43
|
+
collisions: p.collisions,
|
|
44
|
+
collisionsRemediated: p.collisionsRemediated,
|
|
22
45
|
};
|
|
23
46
|
}
|