@consiliency/agent-board-schema 1.35.0 → 1.40.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.
@@ -0,0 +1,107 @@
1
+ -- SIGNEDWRITEMODEL (message-board#326) — a self-describing sibling for
2
+ -- `signed_write_enforced` in the bundle/schema handshake.
3
+ --
4
+ -- THE PROBLEM. After #324 the flag has the right VALUE and the wrong NAME. It is
5
+ -- called "signed write enforced" and it tests "the signed path is operator-only".
6
+ -- Those are different propositions, and a boolean cannot say which one it means.
7
+ -- The consequence is already written down in specs/operations.md: a reader must
8
+ -- combine the flag with `installed_bundle_version` to know which MEANING they are
9
+ -- looking at — below 1.37.0 it tests the model 20260718000800 abandoned, at or
10
+ -- above it tests the current one. That works and it asks every reader to carry a
11
+ -- version boundary in their head.
12
+ --
13
+ -- WHY NOT RENAME OR RETIRE, which is the strongest argument in this area.
14
+ -- packages/agent-board-client/src/compatibility.ts recognises a health response
15
+ -- with a runtime TYPE GUARD:
16
+ -- "schema_version" in value && "signed_write_enforced" in value && "ready" in value
17
+ -- Drop the key and every deployed client stops recognising a HEALTHY board's
18
+ -- response as a health response at all. The other spelling of retirement — keep
19
+ -- the key, pin it false/null forever — is a permanently-lying instrument, which is
20
+ -- precisely the harm #310 names. Retirement loses both ways, which is why #324
21
+ -- re-pointed rather than replaced, and why this migration ADDS.
22
+ --
23
+ -- WHY ADDING IS SAFE, and how far that claim goes. The guard requires those three
24
+ -- keys to be PRESENT; it does not enumerate the payload. Checked rather than
25
+ -- assumed: no consumer in this repository asserts an exhaustive key set over
26
+ -- `get_bundle_compatibility()` — the pgTAP consumers read individual keys with
27
+ -- `->>`, and the client reads named fields. A consumer OUTSIDE this repository
28
+ -- that pins the whole object would still be broken by any added key, which is a
29
+ -- property of adding keys and not of this one.
30
+ --
31
+ -- NOT PROPOSED: gating on it. A sound gate is constructible
32
+ -- (`installed_bundle_version >= 1.37.0 AND flag true`) but semver strings need
33
+ -- real comparison, and the recorded version is what the materializer WROTE, not
34
+ -- necessarily what is APPLIED. That makes a good argument and a bad gate, so this
35
+ -- ships as a decode hint like the boolean beside it.
36
+ --
37
+ -- FUTURE-PROOFING is the actual point. The next posture change adds a new VALUE
38
+ -- here rather than silently re-pointing a boolean for a second time — which is
39
+ -- exactly what #310 was.
40
+ --
41
+ -- CREATE OR REPLACE with the identical signature, so the existing ACL survives.
42
+ -- The grants are restated anyway and the COMMENT rewritten, because a recreated
43
+ -- SECURITY DEFINER function that does not restate them is the gate-bypass this
44
+ -- repo has already shipped once (#118). `anon` is deliberately NOT granted.
45
+
46
+ CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
47
+ RETURNS jsonb
48
+ LANGUAGE sql
49
+ STABLE SECURITY DEFINER
50
+ SET search_path TO 'agent_board', 'pg_temp'
51
+ AS $function$
52
+ select jsonb_build_object(
53
+ 'schema_version', (select version from agent_board.schema_version limit 1),
54
+ 'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
55
+ 'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
56
+ 'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
57
+ 'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
58
+ -- #326: which PROPOSITION the boolean below is answering, said out loud, so a
59
+ -- reader does not have to consult a version boundary to decode it. A constant
60
+ -- rather than a derivation: it names the model this bundle's predicate encodes,
61
+ -- which is a fact about the migration you are reading, not about the database.
62
+ 'signed_write_model', 'operator-only-gateway',
63
+ -- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
64
+ -- unreachable. See 20260918000100's header for what this does and does not establish.
65
+ 'signed_write_enforced', (
66
+ -- The anchor. A schema with no signed gateway grants none of them, which would make the
67
+ -- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
68
+ -- from meaning "nothing to enforce".
69
+ exists (
70
+ select 1
71
+ from pg_catalog.pg_proc p
72
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
73
+ where n.nspname = 'agent_board'
74
+ and p.proname = 'signed_send_direct_message'
75
+ )
76
+ and not exists (
77
+ -- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
78
+ -- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
79
+ select 1
80
+ from pg_catalog.pg_proc p
81
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
82
+ cross join (values ('authenticated'), ('anon')) as r(rolname)
83
+ where n.nspname = 'agent_board'
84
+ and p.proname like 'signed\_%'
85
+ and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
86
+ )
87
+ ),
88
+ -- Ready only once the materializer has recorded an install AND the schema
89
+ -- version handshake resolves.
90
+ 'ready', (
91
+ (select version from agent_board.schema_version limit 1) is not null
92
+ and exists (select 1 from agent_board.bundle_installation)
93
+ ),
94
+ 'checked_at', now()
95
+ );
96
+ $function$;
97
+
98
+ -- Restated rather than relied upon. CREATE OR REPLACE preserves the ACL, but this repo has
99
+ -- already shipped a gate-bypass by recreating a SECURITY DEFINER function without restating its
100
+ -- privileges, so the rule is to restate them in the same migration every time. `anon` is
101
+ -- deliberately NOT granted: it did not hold EXECUTE before this migration and must not gain it.
102
+ revoke all on function agent_board.get_bundle_compatibility() from public;
103
+ grant execute on function agent_board.get_bundle_compatibility() to authenticated;
104
+ grant execute on function agent_board.get_bundle_compatibility() to service_role;
105
+
106
+ comment on function agent_board.get_bundle_compatibility() is
107
+ 'Bundle/schema handshake for adopters. `signed_write_model` NAMES the proposition the boolean beside it answers -- `operator-only-gateway` means "the signed gateway exists and no agent_board.signed_* function (all overloads, via has_function_privilege) is EXECUTE-able by authenticated or anon". Read the pair: the model says WHAT is being tested, `signed_write_enforced` says whether it HOLDS. PRESENCE of this key implies the current meaning and needs no semver comparison. ABSENCE does NOT imply the retired one: the boolean changed meaning at bundle 1.37.0 and this key starts at 1.38.0, so on exactly 1.37.x the key is absent while a FALSE is a REAL regression. Absent means consult installed_bundle_version, not "nothing to see". Neither field is a gate. Neither tests that a writer derives its actor from the session and gates -- that half is asserted in agent_board_signed_write_enforced.test.sql, and ONLY for the unsigned counterpart of a signed_* gateway, so a writer with no signed twin is outside both instruments. #310, #326.';
@@ -0,0 +1,119 @@
1
+ -- #341 — `current_agent_id()`'s credential check never ran. Anywhere.
2
+ --
3
+ -- THE DEFECT, corrected after a correctness seat disproved this header's first version by
4
+ -- measurement. The original text said PostgREST sets the per-claim GUCs and Realtime does not, so
5
+ -- the credential branch was dead only for subscribers. That is FALSE, and the truth is worse.
6
+ --
7
+ -- Measured through a real PostgREST (v14.5) with a signed `authenticated` JWT carrying
8
+ -- `agent_credential_id`, by a probe function called over HTTP:
9
+ --
10
+ -- per_claim_sub : null
11
+ -- per_claim_cred : null
12
+ -- whole_json_present : true
13
+ -- whole_json_has_cred : true
14
+ --
15
+ -- PostgREST REMOVED the legacy `request.jwt.claim.<name>` GUCs (the option went away in v12), and
16
+ -- nothing in this repository sets them either. So the pre-fix body, which read ONLY that source,
17
+ -- resolved the claim to NULL on EVERY transport. NULL takes the permissive branch by design, and
18
+ -- the branch requiring a non-revoked, non-expired credential NEVER EXECUTED — not on Realtime,
19
+ -- and not on the RPC path either.
20
+ --
21
+ -- CONSEQUENCE, stated plainly because the first version of this header understated its own fix:
22
+ -- mid-JWT-lifetime credential revocation stopped nothing, anywhere. This migration does not
23
+ -- "restore" a gate that worked on one path; it ACTIVATES the credential gate for the first time,
24
+ -- on every path. That is a real behaviour change on the live RPC path, not only a subscription fix.
25
+ --
26
+ -- WHY AUTH.UID() SURVIVED THE SAME GAP, which is where the fix comes from. `auth.uid()` already
27
+ -- reads BOTH sources and coalesces:
28
+ --
29
+ -- coalesce(
30
+ -- nullif(current_setting('request.jwt.claim.sub', true), ''),
31
+ -- (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
32
+ -- )::uuid
33
+ --
34
+ -- The second source is populated on both transports, and carries custom claims. Verified on
35
+ -- Realtime with a throwaway table whose RLS policy keyed ONLY on that GUC (a row wanting the JWT's
36
+ -- value delivered, a row wanting a different value not), and on PostgREST by the probe above.
37
+ --
38
+ -- THE FIX is that same coalesce shape applied to the credential claim. Nothing is loosened:
39
+ -- absence in BOTH sources still takes the permissive branch, which callers that legitimately have
40
+ -- no credential depend on.
41
+ --
42
+ -- NO NEW RAISE PATH — and the first version of this header was wrong about that too. It claimed
43
+ -- the cast to `jsonb` was "a way this function can now raise where it previously could not". The
44
+ -- previous body raised as well: with a per-claim value of `bogus` it raises
45
+ -- `invalid input syntax for type uuid (22P02)`, measured on the pre-fix body. The UUID regex does
46
+ -- NOT guard the cast — the cast is evaluated at SQL-function startup, before any qual filtering,
47
+ -- so a malformed value raises regardless of a conjunct that syntactically precedes it.
48
+ --
49
+ -- Rather than disclose that, this migration REMOVES the raise class: the credential is compared as
50
+ -- TEXT against `ac.id::text`, so a malformed claim of any shape (`""`, `42`, `{"a":1}`, `bogus`)
51
+ -- matches nothing and DENIES CLEANLY instead of erroring. `lower()` because `uuid::text` is
52
+ -- canonical lowercase and the claim may not be. The regex is kept as a cheap pre-filter, and is
53
+ -- now genuinely a filter rather than a guard that never guarded.
54
+ --
55
+ -- WHAT THIS DOES NOT DO. It does not make a Realtime payload trustworthy. Delivery is now gated on
56
+ -- the same credential the RPC path checks, but the event body is still a replication row image and
57
+ -- the subscription can silently miss events (`SUBSCRIBED` is reported before events actually flow —
58
+ -- measured: on a cold Realtime container the first insert after SUBSCRIBED was dropped). Consumers
59
+ -- must treat an event as "something changed, go fetch". That posture is asserted in the client.
60
+ --
61
+ -- CREATE OR REPLACE with the identical signature, so the ACL survives — verified before writing
62
+ -- this: `authenticated` and `service_role` hold EXECUTE, `anon` does not, PUBLIC holds nothing.
63
+ -- Restated anyway, because a recreated SECURITY DEFINER function that does not restate its
64
+ -- privileges is a gate-bypass this repo has already shipped once.
65
+
66
+ create or replace function agent_board.current_agent_id()
67
+ returns uuid
68
+ language sql
69
+ stable
70
+ security definer
71
+ set search_path = ''
72
+ as $$
73
+ with credential_claim as (
74
+ -- #341: read BOTH sources. PostgREST sets the per-claim GUC; Realtime sets only the
75
+ -- whole-JSON one. Reading a single source made the credential branch dead code for every
76
+ -- Realtime subscriber. Mirrors auth.uid(), which has always coalesced for the same reason.
77
+ select coalesce(
78
+ nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
79
+ -- The OUTER nullif is not redundant, and its absence was a transport inconsistency
80
+ -- caught by a test: the per-claim arm maps '' to absent, so without this the same
81
+ -- logical claim ("" ) would be ABSENT on one transport and a malformed credential
82
+ -- that DENIES on the other. Same input, different answer by transport, which is the
83
+ -- exact bug class #341 is about. Empty means absent on both, matching what omission
84
+ -- already permits.
85
+ nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
86
+ ) as credential_id_text
87
+ )
88
+ select a.id
89
+ from agent_board.agents a
90
+ cross join credential_claim cc
91
+ where a.auth_user_id = auth.uid()
92
+ and a.status = 'active'
93
+ and (
94
+ cc.credential_id_text is null
95
+ or (
96
+ cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
97
+ and exists (
98
+ select 1
99
+ from agent_board.agent_credentials ac
100
+ -- TEXT comparison, never a cast of the untrusted claim: a malformed value must DENY,
101
+ -- not raise. `ac.id::text` is canonical lowercase, hence lower() on the claim.
102
+ where ac.id::text = lower(cc.credential_id_text)
103
+ and ac.agent_id = a.id
104
+ and ac.revoked_at is null
105
+ and (ac.expires_at is null or ac.expires_at > now())
106
+ )
107
+ )
108
+ )
109
+ limit 1;
110
+ $$;
111
+
112
+ -- Restated rather than relied upon. `anon` is deliberately NOT granted: it did not hold EXECUTE
113
+ -- before this migration and must not gain it.
114
+ revoke all on function agent_board.current_agent_id() from public;
115
+ grant execute on function agent_board.current_agent_id() to authenticated;
116
+ grant execute on function agent_board.current_agent_id() to service_role;
117
+
118
+ comment on function agent_board.current_agent_id() is
119
+ 'Resolves auth.uid() to the active agent_board.agents.id and, when an agent_credential_id claim is present in EITHER the per-claim GUC (request.jwt.claim.agent_credential_id) or the whole-JSON GUC (request.jwt.claims), requires it to reference a non-revoked, non-expired credential for that agent. Reading only the per-claim source made the credential branch unreachable on EVERY transport, not just Realtime: PostgREST removed those legacy GUCs at v12 and nothing here sets them, so the claim read NULL and took the permissive branch everywhere. This migration ACTIVATES the credential gate for the first time rather than restoring it (#341). The claim is compared as TEXT against agent_credentials.id::text, so a malformed claim denies cleanly instead of raising 22P02. Absence in BOTH sources still takes the permissive branch, which callers that legitimately have no credential depend on.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.35.0",
3
+ "version": "1.40.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",
@@ -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.35.0",
5
+ "packageVersion": "1.40.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": 134,
50
- "aggregateSha256": "f63027fa44451c55a39fcc8f463df6ae6f4b92765578be053d8bf3b9a2bee07e",
49
+ "migrationCount": 138,
50
+ "aggregateSha256": "e5e6870533c69d20b446431b6fb19693588d9eb4bb55e1fb3f7f239afcfa76c0",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1254,6 +1254,42 @@
1254
1254
  "sourceRef": "message-board",
1255
1255
  "bytes": 84162,
1256
1256
  "sha256": "d9f05e18094954f2e4ee32bf7b129f0a9ffc39bc800a5efa9cf7938e1555c8dc"
1257
+ },
1258
+ {
1259
+ "order": 134,
1260
+ "id": "20260917000100_agent_board_invite_no_downgrade",
1261
+ "filename": "20260917000100_agent_board_invite_no_downgrade.sql",
1262
+ "origin": "message-board-forward",
1263
+ "sourceRef": "message-board",
1264
+ "bytes": 24929,
1265
+ "sha256": "c205a9a679dca059979972b49c89c3b1b3b8f8e2039bd7675a10578f49f9b35a"
1266
+ },
1267
+ {
1268
+ "order": 135,
1269
+ "id": "20260918000100_agent_board_signed_write_enforced_repoint",
1270
+ "filename": "20260918000100_agent_board_signed_write_enforced_repoint.sql",
1271
+ "origin": "message-board-forward",
1272
+ "sourceRef": "message-board",
1273
+ "bytes": 9965,
1274
+ "sha256": "f76dd01744029bfd0088bd4f6cf6fa5cb93e3c9b7fa5559ad83857cf7f4eb8ce"
1275
+ },
1276
+ {
1277
+ "order": 136,
1278
+ "id": "20260919000100_agent_board_signed_write_model_key",
1279
+ "filename": "20260919000100_agent_board_signed_write_model_key.sql",
1280
+ "origin": "message-board-forward",
1281
+ "sourceRef": "message-board",
1282
+ "bytes": 7204,
1283
+ "sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
1284
+ },
1285
+ {
1286
+ "order": 137,
1287
+ "id": "20260920000100_agent_board_realtime_credential_claim",
1288
+ "filename": "20260920000100_agent_board_realtime_credential_claim.sql",
1289
+ "origin": "message-board-forward",
1290
+ "sourceRef": "message-board",
1291
+ "bytes": 7556,
1292
+ "sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
1257
1293
  }
1258
1294
  ],
1259
1295
  "excludedBridge": {