@consiliency/agent-board-schema 1.28.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +40 -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/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +83 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- 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_invite_no_downgrade.test.sql +689 -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_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +256 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +442 -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,301 @@
|
|
|
1
|
+
// Tests for the deployment-drift DECISION.
|
|
2
|
+
//
|
|
3
|
+
// These call `evaluate()` from scripts/lib/drift-evaluate.mjs — the function the script
|
|
4
|
+
// ships and uses. Nothing here reimplements the rule.
|
|
5
|
+
//
|
|
6
|
+
// That matters because the first version of this file did exactly that: it extracted two
|
|
7
|
+
// leaf helpers and wrote the staleness rule locally, while the PR body claimed the tests
|
|
8
|
+
// drove the shipped code. A three-seat review found the claim false and the real control
|
|
9
|
+
// flow untested — including a path where a board with an unparseable timestamp was
|
|
10
|
+
// reported `ok`.
|
|
11
|
+
//
|
|
12
|
+
// WHAT THE `OK` CASES ARE. An earlier version of this header claimed every "reports ok"
|
|
13
|
+
// case below was a fail-open regression test. That was false, and it mattered: it dressed
|
|
14
|
+
// up ordinary coverage as security coverage. There are two different kinds here, and they
|
|
15
|
+
// are labelled:
|
|
16
|
+
// - FAIL-OPEN regression: the case returned `ok` in a previous version and must NOT.
|
|
17
|
+
// Those all assert UNKNOWN or DRIFT — see "UNKNOWN never collapses into OK" and
|
|
18
|
+
// "second-round fail-open regressions".
|
|
19
|
+
// - HAPPY-PATH / BOUNDARY / SKEW: the case must return OK, and the test exists so the
|
|
20
|
+
// check cannot be made trivially strict. A gate that fires on the normal state gets
|
|
21
|
+
// ignored, which is its own failure mode. These are the OK assertions at "the happy
|
|
22
|
+
// path is genuinely reachable", "does NOT flag a small, fresh gap", "tolerates a
|
|
23
|
+
// minute of clock skew", "pins the exact threshold boundary", and the behind-board
|
|
24
|
+
// case in "branches a mutation must not survive".
|
|
25
|
+
//
|
|
26
|
+
// The script itself — argv parsing, credential scrubbing, and the exit-code mapping this
|
|
27
|
+
// whole PR exists for — is NOT exercised here. It is exercised as a subprocess in
|
|
28
|
+
// deployment-drift-script.test.mjs, because none of it is reachable through `evaluate()`.
|
|
29
|
+
import { describe, it, expect } from "vitest";
|
|
30
|
+
import { evaluate, minorGap, pgTimestampToIso, OK, DRIFT, UNKNOWN } from "../../../scripts/lib/drift-evaluate.mjs";
|
|
31
|
+
|
|
32
|
+
const NOW = new Date("2026-08-16T18:00:00Z").getTime();
|
|
33
|
+
const BUMP = "2026-08-16T17:00:00Z";
|
|
34
|
+
const base = (over = {}) => ({
|
|
35
|
+
merged: "1.35.0",
|
|
36
|
+
published: "1.35.0",
|
|
37
|
+
bumpIso: BUMP,
|
|
38
|
+
expectedSchemaVersion: 8,
|
|
39
|
+
expectedDigest: "f63027fa",
|
|
40
|
+
boards: [],
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
const run = (facts, opts = {}) => evaluate(facts, { nowMs: NOW, ...opts });
|
|
44
|
+
const board = (over = {}) => ({
|
|
45
|
+
ref: "b1",
|
|
46
|
+
running: "1.35.0",
|
|
47
|
+
schemaVersion: 8,
|
|
48
|
+
digest: "f63027fa",
|
|
49
|
+
installedAt: "2026-08-16 17:30:00+00",
|
|
50
|
+
...over,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("the happy path is genuinely reachable", () => {
|
|
54
|
+
it("is OK when everything agrees", () => {
|
|
55
|
+
const r = run(base({ boards: [board()] }));
|
|
56
|
+
expect(r.status).toBe(OK);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Each of these returned `ok` + exit 0 in the first version. That was the defect.
|
|
61
|
+
describe("UNKNOWN never collapses into OK", () => {
|
|
62
|
+
it("npm unreachable is UNKNOWN, not current", () => {
|
|
63
|
+
expect(run(base({ published: null })).status).toBe(UNKNOWN);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("an unreadable board is UNKNOWN, not current", () => {
|
|
67
|
+
expect(run(base({ boards: [{ ref: "b1", error: "HTTP 401" }] })).status).toBe(UNKNOWN);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a missing credential is UNKNOWN, not current", () => {
|
|
71
|
+
expect(run(base({ boards: [{ ref: "b1", error: "no SUPABASE_ACCESS_TOKEN" }] })).status).toBe(UNKNOWN);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("an unparseable install timestamp is UNKNOWN — the specific bug a seat found", () => {
|
|
75
|
+
const r = run(base({ boards: [board({ installedAt: "not-a-date" })] }));
|
|
76
|
+
expect(r.status).toBe(UNKNOWN);
|
|
77
|
+
expect(r.findings.some((f) => /unparseable/.test(f.msg))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a missing install timestamp is UNKNOWN", () => {
|
|
81
|
+
expect(run(base({ boards: [board({ installedAt: null })] })).status).toBe(UNKNOWN);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("no bump date (shallow clone) is UNKNOWN for the board leg", () => {
|
|
85
|
+
expect(run(base({ bumpIso: null, boards: [board()] })).status).toBe(UNKNOWN);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a major-version difference is UNKNOWN, not OK", () => {
|
|
89
|
+
expect(run(base({ published: "2.0.0" })).status).toBe(UNKNOWN);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("a malformed merged version is UNKNOWN and stops there", () => {
|
|
93
|
+
const r = run(base({ merged: "not-a-version" }));
|
|
94
|
+
expect(r.status).toBe(UNKNOWN);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a board record with no digest is UNKNOWN — cannot confirm same bytes", () => {
|
|
98
|
+
expect(run(base({ boards: [board({ digest: null })] })).status).toBe(UNKNOWN);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("real drift is DRIFT", () => {
|
|
103
|
+
it("flags more unpublished versions than the threshold", () => {
|
|
104
|
+
expect(run(base({ merged: "1.42.0" })).status).toBe(DRIFT);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does NOT flag a small, fresh gap — a check that cries wolf gets ignored", () => {
|
|
108
|
+
expect(run(base({ merged: "1.36.0" })).status).toBe(OK);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("flags a small gap that has gone stale by age", () => {
|
|
112
|
+
const old = new Date("2026-08-01T17:00:00Z").toISOString();
|
|
113
|
+
expect(run(base({ merged: "1.36.0", bumpIso: old })).status).toBe(DRIFT);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("flags a registry AHEAD of the checkout", () => {
|
|
117
|
+
expect(run(base({ merged: "1.28.0", published: "1.35.0" })).status).toBe(DRIFT);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("flags a board with NO install record — absent is not up to date", () => {
|
|
121
|
+
expect(run(base({ boards: [{ ref: "b1", running: null }] })).status).toBe(DRIFT);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("flags a board behind main", () => {
|
|
125
|
+
expect(run(base({ boards: [board({ running: "1.28.0" })] })).status).toBe(DRIFT);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("flags a record installed before the version it claims existed", () => {
|
|
129
|
+
const r = run(base({ boards: [board({ installedAt: "2026-07-15 02:57:23+00" })] }));
|
|
130
|
+
expect(r.status).toBe(DRIFT);
|
|
131
|
+
expect(r.findings.some((f) => /stale/.test(f.msg))).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("flags a record whose schema_version disagrees with the manifest", () => {
|
|
135
|
+
expect(run(base({ boards: [board({ schemaVersion: 7 })] })).status).toBe(DRIFT);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("flags a record whose digest disagrees with the manifest — different bytes", () => {
|
|
139
|
+
expect(run(base({ boards: [board({ digest: "deadbeef" })] })).status).toBe(DRIFT);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("tolerates a minute of clock skew rather than crying stale", () => {
|
|
143
|
+
expect(run(base({ boards: [board({ installedAt: "2026-08-16 16:59:30+00" })] })).status).toBe(OK);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("pgTimestampToIso", () => {
|
|
148
|
+
it("parses the Postgres two-digit offset that plain Date() rejects", () => {
|
|
149
|
+
expect(pgTimestampToIso("2026-07-15 02:57:23+00")).toBe("2026-07-15T02:57:23+00:00");
|
|
150
|
+
expect(Number.isNaN(new Date("2026-07-15T02:57:23+00").getTime())).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
it("keeps fractional seconds", () => {
|
|
153
|
+
expect(pgTimestampToIso("2026-08-16 17:17:00.04088+00")).toBe("2026-08-16T17:17:00.04088+00:00");
|
|
154
|
+
});
|
|
155
|
+
it("assumes UTC for a naive timestamp", () => {
|
|
156
|
+
expect(pgTimestampToIso("2026-08-16 17:17:00")).toBe("2026-08-16T17:17:00Z");
|
|
157
|
+
});
|
|
158
|
+
it("returns null for unparseable input — unknown, never fresh", () => {
|
|
159
|
+
for (const v of ["not-a-date", null, undefined, ""]) expect(pgTimestampToIso(v)).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("minorGap", () => {
|
|
164
|
+
it("counts unpublished versions", () => expect(minorGap("1.35.0", "1.28.0")).toBe(7));
|
|
165
|
+
it("is zero when equal", () => expect(minorGap("1.35.0", "1.35.0")).toBe(0));
|
|
166
|
+
it("goes negative when the registry is ahead", () => expect(minorGap("1.28.0", "1.35.0")).toBe(-7));
|
|
167
|
+
it("refuses across a major", () => expect(minorGap("2.0.0", "1.35.0")).toBeNull());
|
|
168
|
+
it("returns null rather than guessing", () => expect(minorGap("nope", "1.35.0")).toBeNull());
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Second-round findings. Every case below reported OK in the first rewrite.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
describe("second-round fail-open regressions", () => {
|
|
175
|
+
it("an unpublished gap with an undeterminable bump date is UNKNOWN, not OK", () => {
|
|
176
|
+
expect(run(base({ merged: "1.36.0", bumpIso: null })).status).toBe(UNKNOWN);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a board behind main with an undeterminable bump date is UNKNOWN, not OK", () => {
|
|
180
|
+
expect(run(base({ bumpIso: null, boards: [board({ running: "1.34.0" })] })).status).toBe(UNKNOWN);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("missing manifest expectations make the consistency leg UNKNOWN, not a silent skip", () => {
|
|
184
|
+
// Three seats reproduced this: a corrupt manifest disabled the digest and
|
|
185
|
+
// schema_version comparisons while still printing "record consistent".
|
|
186
|
+
const r = run(base({ expectedDigest: null, expectedSchemaVersion: null, boards: [board({ digest: "deadbeef" })] }));
|
|
187
|
+
expect(r.status).toBe(UNKNOWN);
|
|
188
|
+
expect(r.findings.some((f) => /record consistent/.test(f.msg))).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a board record with no schema_version is UNKNOWN", () => {
|
|
192
|
+
expect(run(base({ boards: [board({ schemaVersion: null })] })).status).toBe(UNKNOWN);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("a malformed version does not parse as its numeric prefix", () => {
|
|
196
|
+
expect(minorGap("1.35.0garbage", "1.35.0")).toBeNull();
|
|
197
|
+
expect(run(base({ published: "1.35.0garbage" })).status).toBe(UNKNOWN);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("a published PATCH ahead is drift, not agreement", () => {
|
|
201
|
+
// minor-only comparison called 1.35.1 equal to 1.35.0 while the registry was ahead
|
|
202
|
+
expect(run(base({ published: "1.35.1" })).status).toBe(DRIFT);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("non-finite thresholds refuse to evaluate rather than passing everything", () => {
|
|
206
|
+
expect(run(base({ merged: "1.58.0" }), { maxVersions: NaN }).status).toBe(UNKNOWN);
|
|
207
|
+
expect(run(base({ merged: "1.58.0" }), { maxDays: NaN }).status).toBe(UNKNOWN);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("status accumulation — previously unpinned", () => {
|
|
212
|
+
it("DRIFT outranks UNKNOWN when both are present", () => {
|
|
213
|
+
// A RANK swap survived the first suite because no case mixed the two.
|
|
214
|
+
const r = run(base({ published: null, boards: [board({ running: "1.20.0" })] }));
|
|
215
|
+
expect(r.findings.some((f) => f.level === UNKNOWN)).toBe(true);
|
|
216
|
+
expect(r.findings.some((f) => f.level === DRIFT)).toBe(true);
|
|
217
|
+
expect(r.status).toBe(DRIFT);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("pins the exact threshold boundary", () => {
|
|
221
|
+
expect(run(base({ merged: "1.38.0" })).status).toBe(OK); // gap 3 == maxVersions
|
|
222
|
+
expect(run(base({ merged: "1.39.0" })).status).toBe(DRIFT); // gap 4 > maxVersions
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Branches a mutation must not survive.
|
|
228
|
+
//
|
|
229
|
+
// Each of the four below was measured UNPINNED: the mutation named in its comment was
|
|
230
|
+
// applied to drift-evaluate.mjs and all 38 tests stayed green. A green suite is not
|
|
231
|
+
// evidence; these exist so that each mutation reds a named assertion.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
describe("branches a mutation must not survive", () => {
|
|
234
|
+
// Mutation: delete the `add(UNKNOWN, ...)` for an unparseable bumpIso (:100-102).
|
|
235
|
+
// No board, and merged === published, so this finding is the ONLY non-OK source —
|
|
236
|
+
// without it the status is OK.
|
|
237
|
+
it("an unparseable bump timestamp is UNKNOWN even when every other leg agrees", () => {
|
|
238
|
+
const r = run(base({ bumpIso: "not-a-date", boards: [] }));
|
|
239
|
+
expect(r.status).toBe(UNKNOWN);
|
|
240
|
+
expect(r.findings.some((f) => f.level === UNKNOWN && /version-bump timestamp is unparseable/.test(f.msg))).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Mutation: change the board-leg `add(UNKNOWN, ...)` for an incomparable version to OK.
|
|
244
|
+
it("a board across a MAJOR from merged is UNKNOWN, never OK", () => {
|
|
245
|
+
const r = run(base({ boards: [board({ running: "2.0.0" })] }));
|
|
246
|
+
expect(r.status).toBe(UNKNOWN);
|
|
247
|
+
expect(r.findings.some((f) => f.level === UNKNOWN && /running 2\.0\.0 is not comparable/.test(f.msg))).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Mutation: drop the early `return` after the malformed-merged UNKNOWN (:96). The
|
|
251
|
+
// existing "a malformed merged version is UNKNOWN and stops there" asserts only the
|
|
252
|
+
// status, which the mutation preserves — so it asserted the label, not the stopping.
|
|
253
|
+
it("a malformed merged version STOPS evaluation — exactly one finding, nothing else runs", () => {
|
|
254
|
+
const r = run(base({ merged: "1.x.0", published: "1.35.0", boards: [board(), board({ ref: "b2", digest: "deadbeef" })] }));
|
|
255
|
+
expect(r.status).toBe(UNKNOWN);
|
|
256
|
+
expect(r.findings).toHaveLength(1);
|
|
257
|
+
expect(r.findings[0].msg).toMatch(/cannot compare anything/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Mutation: force the behind-board outcome to DRIFT unconditionally (:158 as numbered
|
|
261
|
+
// before this block was added). One version behind, bumped an hour ago, is inside
|
|
262
|
+
// release cadence and must not fire.
|
|
263
|
+
//
|
|
264
|
+
// Double duty: the ancient `installedAt` here pins the STATED LIMIT that a behind
|
|
265
|
+
// board's staleness check does not run (see the LIMIT comment in drift-evaluate.mjs).
|
|
266
|
+
// If that limit is ever closed, this assertion reds and must be rewritten deliberately
|
|
267
|
+
// rather than the limit being closed by accident.
|
|
268
|
+
// Mutation: delete the `gap < 0` branch, restoring the fall-through. Reproduced by a
|
|
269
|
+
// seat: a board running 1.36.0 against merged 1.35.0 with a MATCHING digest reported
|
|
270
|
+
// status ok, exit 0, and a message asserting the record was consistent. The publish leg
|
|
271
|
+
// has always called this same relation DRIFT; the board leg did not.
|
|
272
|
+
it("a board AHEAD of merged is DRIFT even when its digest and schema_version match", () => {
|
|
273
|
+
const r = run(base({ boards: [board({ running: "1.36.0" })] })); // digest/schemaVersion match
|
|
274
|
+
expect(r.status).toBe(DRIFT);
|
|
275
|
+
expect(r.findings.some((f) => f.level === DRIFT && /is running 1\.36\.0, which is AHEAD of merged/.test(f.msg))).toBe(true);
|
|
276
|
+
// The old fall-through produced this message while returning ok. Its presence would
|
|
277
|
+
// mean the ahead board was evaluated as though it were current.
|
|
278
|
+
expect(r.findings.some((f) => /record consistent/.test(f.msg))).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("a board AHEAD with a MISMATCHED digest is DRIFT for being ahead, not for the bytes", () => {
|
|
282
|
+
// The symmetric case. A board ahead SHOULD disagree with a manifest describing merged,
|
|
283
|
+
// so the honest single finding is the ahead-ness; "different bytes" would be noise
|
|
284
|
+
// dressed as a second defect.
|
|
285
|
+
const r = run(base({ boards: [board({ running: "1.36.0", digest: "deadbeef", schemaVersion: 9 })] }));
|
|
286
|
+
expect(r.status).toBe(DRIFT);
|
|
287
|
+
expect(r.findings.some((f) => f.level === DRIFT && /AHEAD of merged/.test(f.msg))).toBe(true);
|
|
288
|
+
expect(r.findings.some((f) => /different bytes/.test(f.msg))).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a board a PATCH ahead is DRIFT too — the half-step sign is classified", () => {
|
|
292
|
+
// minorGap returns ±0.5 for a patch difference; -0.5 took the same fall-through.
|
|
293
|
+
expect(run(base({ boards: [board({ running: "1.35.1" })] })).status).toBe(DRIFT);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("a board 1 behind and fresh is OK even with an ancient install timestamp", () => {
|
|
297
|
+
const r = run(base({ boards: [board({ running: "1.34.0", installedAt: "2020-01-01 00:00:00+00" })] }));
|
|
298
|
+
expect(r.status).toBe(OK);
|
|
299
|
+
expect(r.findings.some((f) => f.level === OK && /is 1 schema version\(s\) behind main/.test(f.msg))).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
});
|
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 + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 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 + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 137)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(137);
|
|
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,
|
|
@@ -141,6 +141,87 @@ describe('manifest integrity', () => {
|
|
|
141
141
|
// MBCONSENT-B(iii) (#109/#110 + the gating race): validate and mutate the SAME
|
|
142
142
|
// row. The sink gains an optional row identity and event kind.
|
|
143
143
|
'20260810000100_agent_board_consent_locking.sql',
|
|
144
|
+
// RECEIPTSUBJECTGATE (#287): a read receipt is a MEMO of access, never a SOURCE of it --
|
|
145
|
+
// mark_read requires the NAMED agent to pass the thread-access predicate, closing the
|
|
146
|
+
// route by which service_role could mint an inherit-thread grant for a third party. The
|
|
147
|
+
// predicate is parameterized (_agent_can_access_thread) and the current-agent form
|
|
148
|
+
// recomposed from it, so the disjunct set has ONE definition. The receipt grandfather is
|
|
149
|
+
// deliberately preserved and documented, not removed.
|
|
150
|
+
'20260912000100_agent_board_receipt_subject_gate.sql',
|
|
151
|
+
// CLAIMSUBJECTGATE (#291): a claim is a LOCK, never a KEY -- acquire_claim requires the
|
|
152
|
+
// NAMED agent to pass the thread-access predicate before a thread-bound claim is written,
|
|
153
|
+
// closing the sibling of the #287 mint on agent_board.claims (and, through the thread
|
|
154
|
+
// disjunct of _current_agent_can_access_task, on task access). The permanence of a
|
|
155
|
+
// released or expired claim is RATIFIED here rather than fixed, in comment on function
|
|
156
|
+
// and in specs/security.md, so a future reader cannot mistake it for an oversight.
|
|
157
|
+
'20260914000100_agent_board_claim_subject_gate.sql',
|
|
158
|
+
// TASKTHREADLOCKORDER (#281 R1): one total order for (threads, tasks) -- threads BEFORE
|
|
159
|
+
// tasks -- in append_task_event (an explicit `threads ... for key share` hoisted above the
|
|
160
|
+
// existing `tasks ... for update`, with a recheck of the pre-read thread_id) and in
|
|
161
|
+
// _upsert_delivery_recipient (the OBSGATE thread stamp hoisted above the
|
|
162
|
+
// delivery_recipients insert, whose FK takes KEY SHARE on tasks). Definition-only.
|
|
163
|
+
'20260913000100_agent_board_task_thread_lock_order.sql',
|
|
164
|
+
// FEDSESSIONIDENTITYGATE (#246): a federated human is returned only to the identity that
|
|
165
|
+
// OWNS it. The owner uid is sha256(issuer || '|' || subject), a concatenation with no
|
|
166
|
+
// length prefix and no escaping, so two distinct principals can derive the same uid --
|
|
167
|
+
// and the return path checked only `active` and `kind = 'human'`, neither of which says
|
|
168
|
+
// the row belongs to the caller. Both return sites now require the agent's stored
|
|
169
|
+
// metadata.federation issuer AND subject to equal the caller's verified pair: the
|
|
170
|
+
// idempotent branch and the unique_violation race handler, which returned an existing
|
|
171
|
+
// row with no comparison either. A missing stamp is a refusal (coalesce(..., false)),
|
|
172
|
+
// not a pass. The ambiguous DERIVATION is deliberately unchanged -- that is a separate
|
|
173
|
+
// migration with an auth.users.email re-key -- because this gate defeats the collision
|
|
174
|
+
// with the encoding still in place.
|
|
175
|
+
'20260915000100_agent_board_fedsession_stored_identity_gate.sql',
|
|
176
|
+
// FEDIDENTITYSLUG (#246): the (issuer, subject) -> owner slug derivation becomes ONE
|
|
177
|
+
// injective serialization, and the ambiguous one is dropped rather than kept beside it.
|
|
178
|
+
// `issuer || '|' || subject` has no length prefix and no escaping, so the pair is not
|
|
179
|
+
// recoverable from the preimage and two principals can derive one slug -- and the slug
|
|
180
|
+
// is not a label, it is the auth.users key _ensure_agent_auth_user builds the email
|
|
181
|
+
// from. All five live derivation sites now call agent_board._federated_identity_slug,
|
|
182
|
+
// which length-prefixes each component. NOT definition-only: the migration also
|
|
183
|
+
// BACKFILLS every existing federated owner across all four surfaces that carry the
|
|
184
|
+
// slug -- auth.users.email, raw_user_meta_data.slug, auth.identities, and the human
|
|
185
|
+
// agent's handle -- because re-deriving without re-keying the email orphans the owner
|
|
186
|
+
// (the #244 resumption lookup misses and the non-null owner conjunct fails). The owner
|
|
187
|
+
// uuid is unchanged, so no FK and no live session moves.
|
|
188
|
+
'20260916000100_agent_board_federated_identity_injective_slug.sql',
|
|
189
|
+
// #317 INVITENODOWNGRADE. `_upsert_board_membership_state` is the sole sink that writes
|
|
190
|
+
// membership_state, and it wrote p_membership_state unconditionally -- so an invite (or a
|
|
191
|
+
// join request) landing on an agent who is already `joined`/`subscribed` REPLACED active
|
|
192
|
+
// standing with the weaker offer state. Reproduced on production. Distinct from #109, which
|
|
193
|
+
// was a wrong-ROW defect fixed by p_membership_id targeting; this is the right row moved in
|
|
194
|
+
// the wrong DIRECTION. `invited`/`requested` are offers, so applying one to an existing
|
|
195
|
+
// membership is now a no-op on membership_state (not an exception -- invite is idempotent by
|
|
196
|
+
// design and raising would break callers that succeed today). Every other transition,
|
|
197
|
+
// including re-invite after `removed`, is unchanged. The sink also now emits the RESULTING
|
|
198
|
+
// ROW's state rather than the requested argument: before the guard the two could not
|
|
199
|
+
// differ, and after it a no-op offer would otherwise append `invite_offer -> invited` to
|
|
200
|
+
// the append-only ledger beside a row that says `joined` -- the same discipline
|
|
201
|
+
// _redeem_unbounded_invite_for_agent already follows.
|
|
202
|
+
'20260917000100_agent_board_invite_no_downgrade.sql',
|
|
203
|
+
// #310 SIGNEDWRITEFLAG. `get_bundle_compatibility` reported
|
|
204
|
+
// `signed_write_enforced: false` on a system that IS enforcing, because the predicate
|
|
205
|
+
// still encoded the model 20260718000800 deliberately abandoned: it asked whether
|
|
206
|
+
// `authenticated` can EXECUTE four raw write RPCs. There are SEVEN signed gateways, not
|
|
207
|
+
// four, and the send family is OVERLOADED, so the old hand list was wrong twice over.
|
|
208
|
+
// The flag now reads pg_proc rows for `signed\_%` and asks `has_function_privilege`
|
|
209
|
+
// (which resolves inherited and PUBLIC-default grants that `role_routine_grants`
|
|
210
|
+
// structurally cannot see -- the #302 fail-open shape and the #118 recreate class).
|
|
211
|
+
// Definition-only: CREATE OR REPLACE with the identical signature, so the ACL survives;
|
|
212
|
+
// grants and a COMMENT are restated in the same migration anyway.
|
|
213
|
+
'20260918000100_agent_board_signed_write_enforced_repoint.sql',
|
|
214
|
+
// #326 SIGNEDWRITEMODEL. `signed_write_enforced` has the right VALUE and the wrong
|
|
215
|
+
// NAME: it is called "signed write enforced" and it tests "the signed path is
|
|
216
|
+
// operator-only". A boolean cannot say which proposition it means, so a reader had to
|
|
217
|
+
// combine it with `installed_bundle_version` and a 1.37.0 boundary to decode it. Adds a
|
|
218
|
+
// self-describing `signed_write_model` sibling ('operator-only-gateway'). ADDITIVE: the
|
|
219
|
+
// client's runtime type guard requires three keys to be PRESENT, so dropping or
|
|
220
|
+
// re-pointing the boolean was not available -- the first breaks every deployed client's
|
|
221
|
+
// recognition of a HEALTHY response, the second is the permanently-lying instrument #310
|
|
222
|
+
// names. Definition-only: CREATE OR REPLACE with the identical signature, grants and
|
|
223
|
+
// COMMENT restated in the same migration.
|
|
224
|
+
'20260919000100_agent_board_signed_write_model_key.sql',
|
|
144
225
|
]) {
|
|
145
226
|
expect(names.has(f)).toBe(true);
|
|
146
227
|
}
|
|
@@ -110,12 +110,21 @@
|
|
|
110
110
|
-- demonstrated silenced that way (#198). They are presence claims, so a
|
|
111
111
|
-- comment can only make a MISSING construct look present -- the same
|
|
112
112
|
-- fail-open direction, on weaker claims;
|
|
113
|
-
-- * the
|
|
114
|
-
--
|
|
115
|
-
--
|
|
116
|
-
--
|
|
117
|
-
--
|
|
118
|
-
--
|
|
113
|
+
-- * the ONE remaining deliberate raw read, added by message-board#277: the
|
|
114
|
+
-- KNOWN-STALE record for set_board_authority_ceiling's inline every-reader
|
|
115
|
+
-- claim. Its evidence IS a comment, so a stripped body cannot see it by
|
|
116
|
+
-- construction. It is a PRESENCE claim, so the fail-open direction is "a
|
|
117
|
+
-- comment makes a missing thing look present" -- which is exactly what it is
|
|
118
|
+
-- asserting, deliberately.
|
|
119
|
+
--
|
|
120
|
+
-- CORRECTED BY message-board#277, because this paragraph was itself an example of
|
|
121
|
+
-- the defect #277 fixes -- prose claiming what the code no longer does. It used to
|
|
122
|
+
-- say the catalog CLASS assertions at the end decide serialized/UNSERIALIZED with
|
|
123
|
+
-- `strpos(p.prosrc, …)` over RAW prosrc. They do not, and had not since #196/#199:
|
|
124
|
+
-- the serialization state and the surface-set predicates all read
|
|
125
|
+
-- agent_board_test.stripped_body(), and one surface predicate reads
|
|
126
|
+
-- stripped_body(oid, true) because its marker is a string literal. Verified against
|
|
127
|
+
-- the statements themselves, not against this list.
|
|
119
128
|
-- All of it is inside exact-set or presence assertions that #196 Bucket A
|
|
120
129
|
-- deliberately excluded; it belongs to the follow-up that closes Buckets B and C,
|
|
121
130
|
-- together with the nesting hole in the strip described above.
|
|
@@ -123,7 +132,7 @@ create extension if not exists pgtap with schema extensions;
|
|
|
123
132
|
set search_path = extensions, public, agent_board;
|
|
124
133
|
|
|
125
134
|
begin;
|
|
126
|
-
select plan(
|
|
135
|
+
select plan(58);
|
|
127
136
|
|
|
128
137
|
-- ---------------------------------------------------------------------------
|
|
129
138
|
-- The stripped-body reader every positional assertion below uses is
|
|
@@ -491,33 +500,54 @@ select ok(
|
|
|
491
500
|
'the ceiling setter holds the same lock before its owner gate and upsert [BACKSTOP: tests/isolation/specs/ceiling_setter_lock_before_upsert.spec]'
|
|
492
501
|
);
|
|
493
502
|
|
|
503
|
+
-- THE CEILING CLASSES, DERIVED ONCE (message-board#277). Both sides of this lock
|
|
504
|
+
-- -- who READS the ceiling for authorization, and who WRITES it -- are needed by
|
|
505
|
+
-- four assertions below, one of which compares them to the lock helper's own
|
|
506
|
+
-- COMMENT. Written out four times they would be four things that can drift; the
|
|
507
|
+
-- drift would show up as one of them failing, but only after someone read four
|
|
508
|
+
-- copies to work out which was right. So the predicates live here, once.
|
|
509
|
+
--
|
|
510
|
+
-- CALL SHAPE for the serialization state, not strpos on a bare name: that is the
|
|
511
|
+
-- dangerous direction -- a reader that DROPS the lock but keeps a variable named
|
|
512
|
+
-- after it would still print "serialized".
|
|
513
|
+
-- SCHEMA-QUALIFIED, because a bare-name anchor proves call SYNTAX, not callee
|
|
514
|
+
-- IDENTITY: `\m_lock_board_authority_ceiling\s*\(` also matches
|
|
515
|
+
-- `zz_other._lock_board_authority_ceiling(...)` (measured true). Every live call
|
|
516
|
+
-- site in agent_board is `agent_board.`-qualified, so requiring the qualification
|
|
517
|
+
-- costs nothing today and is fail-CLOSED if someone later writes an unqualified
|
|
518
|
+
-- call (it reads as NOT calling). Overload count is pinned separately, so schema +
|
|
519
|
+
-- name is the identity.
|
|
520
|
+
--
|
|
521
|
+
-- The WRITE predicate names the DML verb and the relation together rather than
|
|
522
|
+
-- "mentions board_authority_ceilings", so _grant_within_ceiling -- which reads the
|
|
523
|
+
-- table and is the reason the read class exists at all -- is not miscounted as a
|
|
524
|
+
-- writer. It sees only STATIC SQL in the function's own body: a write built into an
|
|
525
|
+
-- `execute` string is a literal and is stripped away, and a write performed by a
|
|
526
|
+
-- TRIGGER or by a callee belongs to that object, not to this one. Same blindness
|
|
527
|
+
-- the positional claims above carry and for the same reason; stated here so the
|
|
528
|
+
-- class is not read as "every write to this table", which it is not.
|
|
529
|
+
create temp table _ceiling_class as
|
|
530
|
+
select 'agent_board.' || regexp_replace(p.oid::regprocedure::text, '^([^(]*\.)?', '') as ident,
|
|
531
|
+
p.proname::text as name, -- text, not `name`: array_agg(name) has no is(name[], text[]) overload
|
|
532
|
+
(strpos(lower(agent_board_test.stripped_body(p.oid)), '_grant_within_ceiling') > 0) as is_reader,
|
|
533
|
+
(agent_board_test.stripped_body(p.oid) ~*
|
|
534
|
+
'(insert\s+into|update|delete\s+from|merge\s+into)\s+(only\s+)?(agent_board\.)?board_authority_ceilings\M') as is_writer,
|
|
535
|
+
(agent_board_test.stripped_body(p.oid) ~* '\magent_board\._lock_board_authority_ceiling\s*\(') as serialized
|
|
536
|
+
from pg_proc p
|
|
537
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
538
|
+
where n.nspname = 'agent_board';
|
|
539
|
+
|
|
494
540
|
-- CLASS (catalog-derived, EC-4 form). Every agent_board function that reads the
|
|
495
541
|
-- ceiling for authorization, enumerated from pg_proc — never from a list written
|
|
496
542
|
-- here — each carrying its serialization state POSITIVELY. A new reader changes
|
|
497
543
|
-- the array and NAMES ITSELF in the diff; a reader that is serialized without
|
|
498
|
-
-- being declared here also fails. The
|
|
499
|
-
-- knowingly-unfixed
|
|
500
|
-
-- they carry the same absence race and are out of #186's scope.
|
|
544
|
+
-- being declared here also fails. The two UNSERIALIZED entries are what remains of
|
|
545
|
+
-- the knowingly-unfixed set recorded in 20260901000110's header (it recorded three;
|
|
546
|
+
-- #276 retired one): they carry the same absence race and are out of #186's scope.
|
|
501
547
|
select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below are string literals in the expected array, never invoked
|
|
502
|
-
(select array_agg(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
-- direction -- a reader that DROPS the lock but keeps a variable named
|
|
506
|
-
-- after it would still print "serialized".
|
|
507
|
-
-- SCHEMA-QUALIFIED, because a bare-name anchor proves call SYNTAX, not callee
|
|
508
|
-
-- IDENTITY: `\m_lock_board_authority_ceiling\s*\(` also matches
|
|
509
|
-
-- `zz_other._lock_board_authority_ceiling(...)` (measured true). Every live call
|
|
510
|
-
-- site in agent_board is `agent_board.`-qualified, so requiring the
|
|
511
|
-
-- qualification costs nothing today and is fail-CLOSED if someone later writes
|
|
512
|
-
-- an unqualified call (it reads as NOT calling). Overload count is pinned
|
|
513
|
-
-- separately, so schema + name is the identity.
|
|
514
|
-
case when agent_board_test.stripped_body(p.oid) ~* '\magent_board\._lock_board_authority_ceiling\s*\('
|
|
515
|
-
then 'serialized' else 'UNSERIALIZED' end as entry
|
|
516
|
-
from pg_proc p
|
|
517
|
-
join pg_namespace n on n.oid = p.pronamespace
|
|
518
|
-
where n.nspname = 'agent_board'
|
|
519
|
-
and strpos(lower(agent_board_test.stripped_body(p.oid)), '_grant_within_ceiling') > 0
|
|
520
|
-
) readers),
|
|
548
|
+
(select array_agg(ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end
|
|
549
|
+
order by ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end)
|
|
550
|
+
from _ceiling_class where is_reader),
|
|
521
551
|
array[
|
|
522
552
|
'agent_board._redeem_unbounded_invite_for_agent(text,uuid) => UNSERIALIZED',
|
|
523
553
|
'agent_board.create_invite_link(uuid,text,text,integer,timestamp with time zone,uuid,jsonb) => UNSERIALIZED',
|
|
@@ -532,6 +562,91 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
|
|
|
532
562
|
'every ceiling-authorization reader in pg_proc is declared with its serialization state'
|
|
533
563
|
);
|
|
534
564
|
|
|
565
|
+
-- CLASS (catalog-derived, message-board#277). The WRITE side of the same lock,
|
|
566
|
+
-- declared the same way. The helper's COMMENT makes a claim about writers as well
|
|
567
|
+
-- as readers, and until now only ONE writer was checked -- by name, in the
|
|
568
|
+
-- lock-before-upsert assertion above. A second writer could appear with no lock at
|
|
569
|
+
-- all and nothing here would notice; the comment would still say every writer takes
|
|
570
|
+
-- it. Now it names itself.
|
|
571
|
+
select is( -- pgtap-allow-raise-checked: catalog read only; the RPC name below is a string literal in the expected array, never invoked
|
|
572
|
+
(select array_agg(ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end
|
|
573
|
+
order by ident || ' => ' || case when serialized then 'serialized' else 'UNSERIALIZED' end)
|
|
574
|
+
from _ceiling_class where is_writer),
|
|
575
|
+
array[
|
|
576
|
+
'agent_board.set_board_authority_ceiling(uuid,text,uuid) => serialized'
|
|
577
|
+
]::text[],
|
|
578
|
+
'every function that WRITES board_authority_ceilings is declared with its serialization state'
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
-- The COMMENT on the lock helper is the most discoverable statement of intent about
|
|
582
|
+
-- it -- it is attached to the lock itself -- and until #277 it claimed a property
|
|
583
|
+
-- that the reader class above ACTIVELY REFUTED: "every RPC that writes the ceiling
|
|
584
|
+
-- and every RPC that reads it for authorization takes this FIRST". Two of the four
|
|
585
|
+
-- readers do not take it at all. Correcting the sentence would have fixed today and
|
|
586
|
+
-- left the same rot mechanism in place: prose three files away from the assertion
|
|
587
|
+
-- that knows the answer.
|
|
588
|
+
--
|
|
589
|
+
-- So the COMMENT now carries the two lists in a fixed machine-readable form and this
|
|
590
|
+
-- assertion compares them, name for name and state for state, to the SAME
|
|
591
|
+
-- catalog-derived classes the two assertions above declare. A reader or writer added,
|
|
592
|
+
-- removed, or moved between serialized and UNSERIALIZED fails here as well, and the
|
|
593
|
+
-- failure prints the disagreeing lists side by side.
|
|
594
|
+
--
|
|
595
|
+
-- FAIL-CLOSED ON A MANGLED BLOCK: an absent or malformed block makes substring()
|
|
596
|
+
-- return NULL, which is coalesced to a sentinel that no catalog list can equal --
|
|
597
|
+
-- never to the empty list, which is a real and different answer ('(none)').
|
|
598
|
+
-- Deleting the block is therefore a failure, not a silent pass. Mutation-verified in
|
|
599
|
+
-- all three directions (state flipped, name dropped, block deleted).
|
|
600
|
+
select is(
|
|
601
|
+
(select array[
|
|
602
|
+
coalesce(string_agg(name, ', ' order by name) filter (where is_writer and serialized), '(none)'),
|
|
603
|
+
coalesce(string_agg(name, ', ' order by name) filter (where is_writer and not serialized), '(none)'),
|
|
604
|
+
coalesce(string_agg(name, ', ' order by name) filter (where is_reader and serialized), '(none)'),
|
|
605
|
+
coalesce(string_agg(name, ', ' order by name) filter (where is_reader and not serialized), '(none)')
|
|
606
|
+
] from _ceiling_class),
|
|
607
|
+
(select array[
|
|
608
|
+
coalesce(substring(c from 'WRITERS\[serialized: ([^]]*)\]'), '(NO WRITERS BLOCK IN THE COMMENT)'),
|
|
609
|
+
coalesce(substring(c from 'WRITERS\[serialized: [^]]*\]\[UNSERIALIZED: ([^]]*)\]'), '(NO WRITERS UNSERIALIZED BLOCK IN THE COMMENT)'),
|
|
610
|
+
coalesce(substring(c from 'READERS\[serialized: ([^]]*)\]'), '(NO READERS BLOCK IN THE COMMENT)'),
|
|
611
|
+
coalesce(substring(c from 'READERS\[serialized: [^]]*\]\[UNSERIALIZED: ([^]]*)\]'), '(NO READERS UNSERIALIZED BLOCK IN THE COMMENT)')
|
|
612
|
+
] from (select obj_description('agent_board._lock_board_authority_ceiling(uuid)'::regprocedure, 'pg_proc') as c) d),
|
|
613
|
+
'the ceiling lock COMMENT names exactly the catalog-derived writers and readers, with the same serialization state'
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
-- The COMMENT lists BARE NAMES (onboard_federated_fleet_agent alone has 23
|
|
617
|
+
-- parameters; a signature list would be unreadable and would rot on any signature
|
|
618
|
+
-- change). A bare name is an identity only while no two members of these classes
|
|
619
|
+
-- share one -- otherwise the comparison above would accept the wrong overload's
|
|
620
|
+
-- state. Catalog-derived, so a colliding overload fails here and prints its name.
|
|
621
|
+
select is(
|
|
622
|
+
(select array_agg(name order by name) from (
|
|
623
|
+
select name from _ceiling_class where is_reader or is_writer group by name having count(*) > 1
|
|
624
|
+
) dupes),
|
|
625
|
+
null::text[],
|
|
626
|
+
'no two ceiling readers/writers share a bare name, so the COMMENT may identify them by name'
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
-- KNOWN STALE, RECORDED POSITIVELY (message-board#277). The same overclaim the
|
|
630
|
+
-- helper's COMMENT carried is ALSO an inline comment inside
|
|
631
|
+
-- set_board_authority_ceiling's live body: "Same board-keyed advisory lock every
|
|
632
|
+
-- ceiling READER for authorization takes." It is false in the same way and for the
|
|
633
|
+
-- same two readers. It is NOT corrected in #277 because correcting prose inside a
|
|
634
|
+
-- body means recreating a SECURITY DEFINER function -- a much wider blast radius
|
|
635
|
+
-- than a `comment on function`, and a decision to take deliberately rather than to
|
|
636
|
+
-- smuggle into a comment fix.
|
|
637
|
+
--
|
|
638
|
+
-- Asserted POSITIVELY rather than left as a note in a PR body (CLAUDE.md rule 1: an
|
|
639
|
+
-- exemption states its claim, never relies on absence). WHEN YOU FIX IT: this
|
|
640
|
+
-- assertion fails and names itself -- delete it, and re-derive the COMMENT's blocks
|
|
641
|
+
-- from the catalog while you are there. Raw prosrc on purpose: the evidence IS a
|
|
642
|
+
-- comment, so the stripped body cannot see it (presence claim, not positional).
|
|
643
|
+
select ok(
|
|
644
|
+
(select p.prosrc from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
645
|
+
where n.nspname = 'agent_board' and p.proname = 'set_board_authority_ceiling')
|
|
646
|
+
like '%Same board-keyed advisory lock every ceiling READER for authorization takes%',
|
|
647
|
+
'KNOWN STALE (#277): set_board_authority_ceiling still carries the refuted every-reader claim inline -- pinned here so fixing it cannot go unnoticed'
|
|
648
|
+
);
|
|
649
|
+
|
|
535
650
|
-- CLASS (catalog-derived). Every function that touches this migration set's own
|
|
536
651
|
-- surface — the bounded-invite table, the human revocation registry, the fleet
|
|
537
652
|
-- revocation ledger, or the ceiling lock — with its exact non-owner EXECUTE
|