@team-semicolon/semicolony-cli 4.18.105 → 4.18.107
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/dist/bundle.js +1190 -1182
- package/migrations/201_meeting_audio_data_drop.sql +61 -0
- package/migrations/202_meeting_record_discussion_provenance.sql +173 -0
- package/migrations/202_team_members_record_of_authority.sql +103 -0
- package/migrations/203_action_item_redesign_cutover.sql +1369 -0
- package/migrations/203_ops_meetings_board_grant.sql +45 -0
- package/migrations/204_record_meeting_overload_repair.sql +463 -0
- package/migrations/205_action_item_credential_scope.sql +318 -0
- package/migrations/206_action_scope_narrow_dashboard.sql +29 -0
- package/migrations/207_action_grants_fleet_board.sql +48 -0
- package/migrations/208_action_grants_fleet_board_guard_repair.sql +45 -0
- package/package.json +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
-- 205_action_item_credential_scope.sql
|
|
2
|
+
--
|
|
3
|
+
-- Open the /action/* Gateway lane to the credentials that have to reach it.
|
|
4
|
+
--
|
|
5
|
+
-- Plan 2 tasks 1 and 2 built ActionService and the five /action routes. Every
|
|
6
|
+
-- one of them gates on `action:read` or `action:write` and accepts either a
|
|
7
|
+
-- `worker` credential (the human lane) or a `worker-agent-actor` credential
|
|
8
|
+
-- (the agent lane, semi and colony). No credential in the fleet holds either
|
|
9
|
+
-- scope, so the whole lane answers 403 today. This migration issues the two
|
|
10
|
+
-- scopes and moves both scope contracts that would otherwise refuse them.
|
|
11
|
+
--
|
|
12
|
+
-- Measured live state before this migration (2026-08-13, from the production
|
|
13
|
+
-- schema dump plus the operator's credential read). The two scopes below are
|
|
14
|
+
-- what this file adds; nothing else may change:
|
|
15
|
+
--
|
|
16
|
+
-- metadata->>'device_id' scopes (count)
|
|
17
|
+
-- ---------------------- -------------------------------------------------
|
|
18
|
+
-- reus-macmini kb:read, feedback:write, skills:read, skills:write,
|
|
19
|
+
-- worker:release:read, worker:release:report,
|
|
20
|
+
-- workspace:assets-read, workspace:assets-write (8)
|
|
21
|
+
-- semicolony-agent the same eight plus meetings:write (9)
|
|
22
|
+
-- roki-pc kb:read, skills:read, skills:write,
|
|
23
|
+
-- worker:release:read, worker:release:report (5)
|
|
24
|
+
-- jay-pc kb:read, skills:read, skills:write (3)
|
|
25
|
+
--
|
|
26
|
+
-- worker-agent-actor semi and colony
|
|
27
|
+
-- kb:read, feedback:write, decision:write,
|
|
28
|
+
-- note:write (4)
|
|
29
|
+
--
|
|
30
|
+
-- The worker amendment is therefore additive per credential and never a
|
|
31
|
+
-- wholesale array write. reus-macmini is the only holder of
|
|
32
|
+
-- `workspace:assets-write` and semicolony-agent the only holder of
|
|
33
|
+
-- `meetings:write`; a replacement array sized to the plan's own list would
|
|
34
|
+
-- drop one of those silently, and neither is recoverable from this file.
|
|
35
|
+
--
|
|
36
|
+
-- The two device-bound credentials that must NOT be reached: the
|
|
37
|
+
-- `worker-agent-host` credential (release-only, two scopes, no device_id in
|
|
38
|
+
-- its metadata) and any revoked or expired row. Both are excluded by
|
|
39
|
+
-- predicate, and section 5 asserts it rather than assuming it.
|
|
40
|
+
--
|
|
41
|
+
-- Deploy order. The Gateway must ship before this migration is applied:
|
|
42
|
+
-- `tenant-credentials.ts` gates the actor scope array order-exactly, and
|
|
43
|
+
-- section 4 rewrites that array. The Gateway build that accompanies this
|
|
44
|
+
-- migration accepts the 186 (3-scope), 194 (4-scope) and 205 (6-scope)
|
|
45
|
+
-- contracts, so Gateway-then-migration is safe in either direction and
|
|
46
|
+
-- migration-then-Gateway locks both actors out of every lane they own.
|
|
47
|
+
--
|
|
48
|
+
-- The migration runner owns BEGIN/COMMIT and retargets the `semicolony.`
|
|
49
|
+
-- qualifier to the active schema.
|
|
50
|
+
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
-- 1. Let the two governed worker-credential entry points express the new
|
|
53
|
+
-- scopes.
|
|
54
|
+
--
|
|
55
|
+
-- `issue_worker_gateway_credential` (rotation) and
|
|
56
|
+
-- `amend_worker_gateway_credential_scopes` (in-place amendment) each carry
|
|
57
|
+
-- an identical literal scope allowlist and reject anything outside it.
|
|
58
|
+
-- Backfilling section 2 without widening them would mean the next rotation
|
|
59
|
+
-- of any of the four devices -- worker credentials expire within 90 days by
|
|
60
|
+
-- construction -- either fails outright or silently drops the two scopes.
|
|
61
|
+
-- 200_meeting_credential_scope.sql widened both entry points for
|
|
62
|
+
-- `meetings:write` for exactly this reason; skipping it here would repeat
|
|
63
|
+
-- the `workspace:assets-write` drift that 200's header documents.
|
|
64
|
+
--
|
|
65
|
+
-- The widening rewrites only the allowlist inside the installed definition
|
|
66
|
+
-- (the 187/194 pattern). Copying the ~10KB bodies would silently revert any
|
|
67
|
+
-- correction they have received since the dump was taken.
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
DO $worker_scope_allowlist$
|
|
71
|
+
DECLARE
|
|
72
|
+
v_targets constant text[] := ARRAY[
|
|
73
|
+
'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)',
|
|
74
|
+
'semicolony.amend_worker_gateway_credential_scopes(uuid,text[],text,text)'
|
|
75
|
+
];
|
|
76
|
+
-- The last element of the allowlist plus its closing bracket. Anchoring on
|
|
77
|
+
-- the closing bracket is what makes this unambiguous: 'worker:release:report'
|
|
78
|
+
-- also appears in other functions, but only here immediately before `]`.
|
|
79
|
+
v_old constant text := E' ''worker:release:report''\n ]::text[])';
|
|
80
|
+
v_new constant text := E' ''worker:release:report'',\n'
|
|
81
|
+
|| E' ''action:read'',\n'
|
|
82
|
+
|| E' ''action:write''\n'
|
|
83
|
+
|| E' ]::text[])';
|
|
84
|
+
v_target text;
|
|
85
|
+
v_definition text;
|
|
86
|
+
v_old_occurrences integer;
|
|
87
|
+
BEGIN
|
|
88
|
+
FOREACH v_target IN ARRAY v_targets LOOP
|
|
89
|
+
SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
|
|
90
|
+
|
|
91
|
+
v_old_occurrences :=
|
|
92
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_old, '')))
|
|
93
|
+
/ char_length(v_old);
|
|
94
|
+
|
|
95
|
+
-- Already widened: re-running must be a no-op, not an exception.
|
|
96
|
+
IF v_old_occurrences = 0 AND position(v_new IN v_definition) > 0 THEN
|
|
97
|
+
CONTINUE;
|
|
98
|
+
END IF;
|
|
99
|
+
IF v_old_occurrences <> 1 THEN
|
|
100
|
+
RAISE EXCEPTION 'unexpected worker credential scope allowlist in %', v_target;
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
EXECUTE replace(v_definition, v_old, v_new);
|
|
104
|
+
END LOOP;
|
|
105
|
+
END;
|
|
106
|
+
$worker_scope_allowlist$;
|
|
107
|
+
|
|
108
|
+
-- ---------------------------------------------------------------------------
|
|
109
|
+
-- 2. Grant the two scopes to the device-bound worker credentials.
|
|
110
|
+
--
|
|
111
|
+
-- Keyed on `metadata->>'device_id'` being present, so the actor and host
|
|
112
|
+
-- credentials -- which carry `installation_id` instead -- cannot be reached
|
|
113
|
+
-- from here. Each scope is appended only where it is absent, so a re-run
|
|
114
|
+
-- changes nothing and no existing scope is rewritten.
|
|
115
|
+
-- ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
UPDATE semicolony.gateway_credentials AS gc
|
|
118
|
+
SET scopes = gc.scopes
|
|
119
|
+
|| CASE WHEN 'action:read' = ANY(gc.scopes)
|
|
120
|
+
THEN ARRAY[]::text[] ELSE ARRAY['action:read']::text[] END
|
|
121
|
+
|| CASE WHEN 'action:write' = ANY(gc.scopes)
|
|
122
|
+
THEN ARRAY[]::text[] ELSE ARRAY['action:write']::text[] END
|
|
123
|
+
WHERE gc.status = 'active'
|
|
124
|
+
AND gc.revoked_at IS NULL
|
|
125
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
126
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
127
|
+
AND gc.metadata ->> 'device_id' IS NOT NULL
|
|
128
|
+
AND NOT (gc.scopes @> ARRAY['action:read', 'action:write']::text[]);
|
|
129
|
+
|
|
130
|
+
-- ---------------------------------------------------------------------------
|
|
131
|
+
-- 3. Widen the actor scope contract from four scopes to six.
|
|
132
|
+
--
|
|
133
|
+
-- The worker contract for action items was decided as human AND agent, so
|
|
134
|
+
-- semi and colony must be able to read, create and close their own items.
|
|
135
|
+
-- They cannot today: the actor scope array is compared by exact equality at
|
|
136
|
+
-- three sites, and a credential carrying anything else is refused.
|
|
137
|
+
--
|
|
138
|
+
-- All three sites, measured against the live schema (2026-08-13):
|
|
139
|
+
-- * issue_worker_agent_actor_credential -- the array written at issuance
|
|
140
|
+
-- * record_worker_agent_decision -- `gc.scopes = ARRAY[...]`
|
|
141
|
+
-- * record_worker_agent_note -- `gc.scopes = ARRAY[...]`
|
|
142
|
+
-- Missing any one of them does not fail loudly; it silently refuses the
|
|
143
|
+
-- credential the next time that lane is used. 194 moved the first two when
|
|
144
|
+
-- it added `note:write` and installed the third.
|
|
145
|
+
--
|
|
146
|
+
-- Order. The array is order-exact, so the two scopes are APPENDED, in
|
|
147
|
+
-- migration order (186 -> 194 -> 205) and read-before-write. Appending is
|
|
148
|
+
-- the only change that cannot reinterpret an existing index, which is what
|
|
149
|
+
-- lets the Gateway keep accepting the older contracts by length. The same
|
|
150
|
+
-- order is mirrored in `tenant-credentials.ts`.
|
|
151
|
+
-- ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
DO $actor_scope_widen$
|
|
154
|
+
DECLARE
|
|
155
|
+
v_targets constant text[] := ARRAY[
|
|
156
|
+
'semicolony.issue_worker_agent_actor_credential(uuid,text,text,text,text,integer)',
|
|
157
|
+
'semicolony.record_worker_agent_decision(uuid,uuid,text,text,text,text)',
|
|
158
|
+
'semicolony.record_worker_agent_note(uuid,text,text,text,text)'
|
|
159
|
+
];
|
|
160
|
+
v_old constant text :=
|
|
161
|
+
'ARRAY[''kb:read'', ''feedback:write'', ''decision:write'', ''note:write'']::text[]';
|
|
162
|
+
v_new constant text :=
|
|
163
|
+
'ARRAY[''kb:read'', ''feedback:write'', ''decision:write'', ''note:write'', '
|
|
164
|
+
|| '''action:read'', ''action:write'']::text[]';
|
|
165
|
+
v_target text;
|
|
166
|
+
v_definition text;
|
|
167
|
+
v_old_occurrences integer;
|
|
168
|
+
BEGIN
|
|
169
|
+
FOREACH v_target IN ARRAY v_targets LOOP
|
|
170
|
+
SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
|
|
171
|
+
|
|
172
|
+
v_old_occurrences :=
|
|
173
|
+
(char_length(v_definition) - char_length(replace(v_definition, v_old, '')))
|
|
174
|
+
/ char_length(v_old);
|
|
175
|
+
|
|
176
|
+
IF v_old_occurrences = 0 AND position(v_new IN v_definition) > 0 THEN
|
|
177
|
+
CONTINUE;
|
|
178
|
+
END IF;
|
|
179
|
+
IF v_old_occurrences <> 1 THEN
|
|
180
|
+
RAISE EXCEPTION 'unexpected Worker-agent actor scope definition in %', v_target;
|
|
181
|
+
END IF;
|
|
182
|
+
|
|
183
|
+
EXECUTE replace(v_definition, v_old, v_new);
|
|
184
|
+
END LOOP;
|
|
185
|
+
END;
|
|
186
|
+
$actor_scope_widen$;
|
|
187
|
+
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
-- 4. Move the two live actor credentials onto the widened contract.
|
|
190
|
+
--
|
|
191
|
+
-- Unlike section 2 this is a whole-array write, and it has to be: the three
|
|
192
|
+
-- sites above compare by equality, so the stored array must be exactly the
|
|
193
|
+
-- contract. It is safe here for the same reason -- the actor array is
|
|
194
|
+
-- closed and fully determined -- and unsafe for the worker credentials in
|
|
195
|
+
-- section 2, whose arrays differ per device. The join reproduces 194's
|
|
196
|
+
-- binding predicate so nothing outside the two bound actors is reachable.
|
|
197
|
+
-- ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
UPDATE semicolony.gateway_credentials AS gc
|
|
200
|
+
SET scopes = ARRAY[
|
|
201
|
+
'kb:read', 'feedback:write', 'decision:write', 'note:write',
|
|
202
|
+
'action:read', 'action:write'
|
|
203
|
+
]::text[]
|
|
204
|
+
FROM semicolony.worker_agent_actor_bindings AS binding
|
|
205
|
+
WHERE binding.gateway_credential_id = gc.id
|
|
206
|
+
AND binding.installation_id = 'hermes-principals@hermes'
|
|
207
|
+
AND binding.profile_id = 'semo-ops-worker-agent'
|
|
208
|
+
AND binding.runtime_kind = 'hermes-cli'
|
|
209
|
+
AND binding.actor_id IN ('semi', 'colony')
|
|
210
|
+
AND binding.status = 'active'
|
|
211
|
+
AND gc.status = 'active'
|
|
212
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
213
|
+
AND gc.metadata ->> 'credential_kind' = 'worker-agent-actor'
|
|
214
|
+
AND gc.metadata ->> 'actor_id' = binding.actor_id
|
|
215
|
+
AND gc.metadata ->> 'installation_id' = binding.installation_id
|
|
216
|
+
AND gc.metadata ->> 'profile_id' = binding.profile_id
|
|
217
|
+
AND gc.metadata ->> 'runtime_kind' = binding.runtime_kind
|
|
218
|
+
AND gc.metadata ->> 'runtime_profile' = binding.runtime_profile
|
|
219
|
+
AND gc.scopes
|
|
220
|
+
IS DISTINCT FROM
|
|
221
|
+
ARRAY[
|
|
222
|
+
'kb:read', 'feedback:write', 'decision:write', 'note:write',
|
|
223
|
+
'action:read', 'action:write'
|
|
224
|
+
]::text[];
|
|
225
|
+
|
|
226
|
+
-- ---------------------------------------------------------------------------
|
|
227
|
+
-- 5. Assert the reach, in both directions.
|
|
228
|
+
--
|
|
229
|
+
-- Under-reach (the device filter matched nothing) and over-reach (the
|
|
230
|
+
-- release-only host credential picked up write authority) both fail loudly
|
|
231
|
+
-- here rather than being discovered from a 403 or from an unexpected host
|
|
232
|
+
-- writing an action item.
|
|
233
|
+
-- ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
DO $verify_action_scopes$
|
|
236
|
+
DECLARE
|
|
237
|
+
v_worker_with integer;
|
|
238
|
+
v_worker_without integer;
|
|
239
|
+
v_actor_stale integer;
|
|
240
|
+
v_stray integer;
|
|
241
|
+
v_duplicated integer;
|
|
242
|
+
BEGIN
|
|
243
|
+
SELECT
|
|
244
|
+
count(*) FILTER (
|
|
245
|
+
WHERE gc.scopes @> ARRAY['action:read', 'action:write']::text[]
|
|
246
|
+
),
|
|
247
|
+
count(*) FILTER (
|
|
248
|
+
WHERE NOT (gc.scopes @> ARRAY['action:read', 'action:write']::text[])
|
|
249
|
+
)
|
|
250
|
+
INTO v_worker_with, v_worker_without
|
|
251
|
+
FROM semicolony.gateway_credentials AS gc
|
|
252
|
+
WHERE gc.status = 'active'
|
|
253
|
+
AND gc.revoked_at IS NULL
|
|
254
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
255
|
+
AND gc.metadata ->> 'credential_kind' = 'worker'
|
|
256
|
+
AND gc.metadata ->> 'device_id' IS NOT NULL;
|
|
257
|
+
|
|
258
|
+
IF v_worker_without <> 0 THEN
|
|
259
|
+
RAISE EXCEPTION
|
|
260
|
+
'expected every active device-bound worker credential to hold the action scopes, % still do not',
|
|
261
|
+
v_worker_without;
|
|
262
|
+
END IF;
|
|
263
|
+
-- Four devices were measured. More is fine (a device onboarded since the
|
|
264
|
+
-- measurement is covered by the same predicate); fewer means the filter
|
|
265
|
+
-- matched something other than the fleet.
|
|
266
|
+
IF v_worker_with < 4 THEN
|
|
267
|
+
RAISE EXCEPTION
|
|
268
|
+
'expected at least the 4 measured device-bound worker credentials, found %',
|
|
269
|
+
v_worker_with;
|
|
270
|
+
END IF;
|
|
271
|
+
|
|
272
|
+
SELECT count(*)
|
|
273
|
+
INTO v_actor_stale
|
|
274
|
+
FROM semicolony.gateway_credentials AS gc
|
|
275
|
+
JOIN semicolony.worker_agent_actor_bindings AS binding
|
|
276
|
+
ON binding.gateway_credential_id = gc.id
|
|
277
|
+
AND binding.status = 'active'
|
|
278
|
+
WHERE gc.status = 'active'
|
|
279
|
+
AND gc.revoked_at IS NULL
|
|
280
|
+
AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
|
|
281
|
+
AND gc.metadata ->> 'credential_kind' = 'worker-agent-actor'
|
|
282
|
+
AND gc.scopes IS DISTINCT FROM ARRAY[
|
|
283
|
+
'kb:read', 'feedback:write', 'decision:write', 'note:write',
|
|
284
|
+
'action:read', 'action:write'
|
|
285
|
+
]::text[];
|
|
286
|
+
IF v_actor_stale <> 0 THEN
|
|
287
|
+
RAISE EXCEPTION
|
|
288
|
+
'% active actor credential(s) left on the old contract; section 3 has made them unusable',
|
|
289
|
+
v_actor_stale;
|
|
290
|
+
END IF;
|
|
291
|
+
|
|
292
|
+
SELECT count(*)
|
|
293
|
+
INTO v_stray
|
|
294
|
+
FROM semicolony.gateway_credentials AS gc
|
|
295
|
+
WHERE gc.scopes && ARRAY['action:read', 'action:write']::text[]
|
|
296
|
+
AND gc.metadata ->> 'credential_kind' <> 'worker-agent-actor'
|
|
297
|
+
AND NOT (
|
|
298
|
+
gc.metadata ->> 'credential_kind' = 'worker'
|
|
299
|
+
AND gc.metadata ->> 'device_id' IS NOT NULL
|
|
300
|
+
);
|
|
301
|
+
IF v_stray <> 0 THEN
|
|
302
|
+
RAISE EXCEPTION 'action scopes reached % unintended credential(s)', v_stray;
|
|
303
|
+
END IF;
|
|
304
|
+
|
|
305
|
+
-- Scoped to what this file writes. A legacy duplicate elsewhere is not this
|
|
306
|
+
-- migration's to fail on, but a duplicate here means the append ran twice.
|
|
307
|
+
SELECT count(*)
|
|
308
|
+
INTO v_duplicated
|
|
309
|
+
FROM semicolony.gateway_credentials AS gc
|
|
310
|
+
WHERE gc.scopes && ARRAY['action:read', 'action:write']::text[]
|
|
311
|
+
AND cardinality(gc.scopes) <> (
|
|
312
|
+
SELECT count(DISTINCT scope)::integer FROM unnest(gc.scopes) AS s(scope)
|
|
313
|
+
);
|
|
314
|
+
IF v_duplicated <> 0 THEN
|
|
315
|
+
RAISE EXCEPTION '% credential(s) carry a duplicated scope', v_duplicated;
|
|
316
|
+
END IF;
|
|
317
|
+
END;
|
|
318
|
+
$verify_action_scopes$;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- The semo-ops-dashboard credential gained action:read and action:write from
|
|
2
|
+
-- 205's blanket scope amendment, but the dashboard never calls the Gateway's
|
|
3
|
+
-- action routes -- it reads and writes semo_ops.action_items through direct
|
|
4
|
+
-- queries in lib/core/action-items.ts. Measured 2026-08-14: zero occurrences
|
|
5
|
+
-- of /action/list|create|update|get anywhere in packages/semicolony-dashboard.
|
|
6
|
+
-- Take the unused scopes back.
|
|
7
|
+
UPDATE semicolony.gateway_credentials
|
|
8
|
+
SET scopes = array_remove(array_remove(scopes, 'action:read'), 'action:write')
|
|
9
|
+
WHERE status = 'active'
|
|
10
|
+
AND metadata->>'device_id' = 'semo-ops-dashboard';
|
|
11
|
+
|
|
12
|
+
DO $$
|
|
13
|
+
DECLARE
|
|
14
|
+
remaining text[];
|
|
15
|
+
BEGIN
|
|
16
|
+
SELECT scopes INTO remaining
|
|
17
|
+
FROM semicolony.gateway_credentials
|
|
18
|
+
WHERE status = 'active' AND metadata->>'device_id' = 'semo-ops-dashboard';
|
|
19
|
+
|
|
20
|
+
IF remaining IS NULL THEN
|
|
21
|
+
RAISE EXCEPTION 'semo-ops-dashboard credential not found or not active';
|
|
22
|
+
END IF;
|
|
23
|
+
IF 'workspace:assets-read' <> ALL(remaining) THEN
|
|
24
|
+
RAISE EXCEPTION 'workspace:assets-read was lost: %', remaining;
|
|
25
|
+
END IF;
|
|
26
|
+
IF 'action:read' = ANY(remaining) OR 'action:write' = ANY(remaining) THEN
|
|
27
|
+
RAISE EXCEPTION 'action scopes still present: %', remaining;
|
|
28
|
+
END IF;
|
|
29
|
+
END $$;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- The dashboard's action-item surface moved off appdb.semicolony onto
|
|
2
|
+
-- semo_ops (see KB semicolon decision
|
|
3
|
+
-- semo-ops-dashboard-control-room-data-routing-2026-08-06, and the 42P01
|
|
4
|
+
-- outage measured 2026-08-16). Its pool connects as sc_fleet_board, which
|
|
5
|
+
-- held SELECT only. Writes still reach action_items exclusively through
|
|
6
|
+
-- SECURITY DEFINER functions -- this grants EXECUTE on the three the
|
|
7
|
+
-- dashboard calls (lib/core/action-items.ts: createActionItem,
|
|
8
|
+
-- assignActionItem, operatorUpdateActionItem -- cancelActionItem delegates
|
|
9
|
+
-- to operatorUpdateActionItem rather than calling its own function) and
|
|
10
|
+
-- adds no direct DML. The other five surfaces that moved to the fleet pool
|
|
11
|
+
-- in the same cutover (the /api/action-items roster query, /api/goals,
|
|
12
|
+
-- /api/person/[domain], lib/plugins/service/kpi.ts, lib/tasks-db.ts) are
|
|
13
|
+
-- read-only SELECTs against action_items/team_members/bot_commitments and
|
|
14
|
+
-- call no function, so they need no grant here. No read path selects from
|
|
15
|
+
-- action_item_events, so this migration does not grant SELECT on it either.
|
|
16
|
+
GRANT EXECUTE ON FUNCTION semicolony.create_action_item(
|
|
17
|
+
p_description text, p_project_domain text, p_deadline date,
|
|
18
|
+
p_assignee_kind text, p_assignee_member text, p_assignee_agent text,
|
|
19
|
+
p_assignee_device text, p_priority text, p_source_kind text,
|
|
20
|
+
p_source_ref text, p_related_url text, p_external_contact text,
|
|
21
|
+
p_metadata jsonb, p_origin_surface text, p_origin_actor_kind text,
|
|
22
|
+
p_origin_member text, p_origin_agent text, p_origin_device text,
|
|
23
|
+
p_origin_harness text, p_origin_session_key text
|
|
24
|
+
) TO sc_fleet_board;
|
|
25
|
+
|
|
26
|
+
GRANT EXECUTE ON FUNCTION semicolony.assign_action_item(
|
|
27
|
+
p_action_item_id uuid, p_assignee_kind text, p_assignee_member text,
|
|
28
|
+
p_assignee_agent text, p_assignee_device text, p_actor_kind text,
|
|
29
|
+
p_actor_member text, p_actor_agent text
|
|
30
|
+
) TO sc_fleet_board;
|
|
31
|
+
|
|
32
|
+
GRANT EXECUTE ON FUNCTION semicolony.operator_update_action_item(
|
|
33
|
+
p_action_item_id uuid, p_status text, p_deadline date, p_note text,
|
|
34
|
+
p_metadata jsonb, p_actor_kind text, p_actor_member text,
|
|
35
|
+
p_actor_agent text
|
|
36
|
+
) TO sc_fleet_board;
|
|
37
|
+
|
|
38
|
+
DO $$
|
|
39
|
+
BEGIN
|
|
40
|
+
IF EXISTS (
|
|
41
|
+
SELECT 1 FROM information_schema.table_privileges
|
|
42
|
+
WHERE grantee = 'sc_fleet_board' AND table_schema = 'semicolony'
|
|
43
|
+
AND table_name = 'action_items'
|
|
44
|
+
AND privilege_type IN ('INSERT','UPDATE','DELETE')
|
|
45
|
+
) THEN
|
|
46
|
+
RAISE EXCEPTION 'sc_fleet_board must never hold direct DML on action_items';
|
|
47
|
+
END IF;
|
|
48
|
+
END $$;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- 207_action_grants_fleet_board.sql shipped a safety-net guard that can never
|
|
2
|
+
-- fire in the environment it targets:
|
|
3
|
+
--
|
|
4
|
+
-- WHERE grantee = 'sc_fleet_board' AND table_schema = 'semicolony' ...
|
|
5
|
+
--
|
|
6
|
+
-- `table_schema = 'semicolony'` is a string LITERAL compared against a column,
|
|
7
|
+
-- not a schema-qualified identifier. `retargetSchemaSql`
|
|
8
|
+
-- (packages/cli/src/commands/db.ts:35-44) rewrites `semicolony.` only when it
|
|
9
|
+
-- qualifies an identifier (`semicolony.<name>`, matched by
|
|
10
|
+
-- `(?<![\w./])semicolony\.`) -- a bare string literal with no trailing dot,
|
|
11
|
+
-- like `'semicolony'` in a WHERE clause, is left untouched by design (see
|
|
12
|
+
-- that function's own docstring, which names this exact pattern as excluded).
|
|
13
|
+
-- This migration always runs with SEMICOLONY_DB_SCHEMA=semo_ops against
|
|
14
|
+
-- team_knowledge_fabric, which has no `semicolony` schema at all -- so the
|
|
15
|
+
-- guard's WHERE clause matched zero rows regardless of grant state, and the
|
|
16
|
+
-- RAISE EXCEPTION was dead code. The invariant itself was never violated
|
|
17
|
+
-- (verified separately, see task-3-report.md); only the safety net was inert.
|
|
18
|
+
--
|
|
19
|
+
-- Fix: drop the schema filter rather than try to make it retarget-aware.
|
|
20
|
+
-- `current_schema()` was considered and rejected -- it happens to resolve to
|
|
21
|
+
-- `semo_ops` for the role this migration currently runs as (that role's
|
|
22
|
+
-- search_path defaults to `semo_ops, public, pg_catalog` in this database),
|
|
23
|
+
-- but that is a property of the connecting role's search_path, not of
|
|
24
|
+
-- SEMICOLONY_DB_SCHEMA, and would silently go back to matching nothing under
|
|
25
|
+
-- a role without that search_path -- the same failure mode this migration
|
|
26
|
+
-- exists to close. `action_items` is schema-unique in this database (checked
|
|
27
|
+
-- against information_schema.tables across all schemas: exactly one row,
|
|
28
|
+
-- semo_ops.action_items) so filtering on table_name alone is unambiguous
|
|
29
|
+
-- today, and if a second action_items ever appears anywhere and picks up
|
|
30
|
+
-- direct DML for sc_fleet_board, this guard should fire for that too -- a
|
|
31
|
+
-- schema-agnostic check is the more conservative invariant, not a weaker one.
|
|
32
|
+
--
|
|
33
|
+
-- Record the trap for future authors: a bare string literal inside a WHERE
|
|
34
|
+
-- clause is not an identifier and `retargetSchemaSql` will never rewrite it,
|
|
35
|
+
-- by design, no matter how much it looks like a schema reference.
|
|
36
|
+
DO $$
|
|
37
|
+
BEGIN
|
|
38
|
+
IF EXISTS (
|
|
39
|
+
SELECT 1 FROM information_schema.table_privileges
|
|
40
|
+
WHERE grantee = 'sc_fleet_board' AND table_name = 'action_items'
|
|
41
|
+
AND privilege_type IN ('INSERT','UPDATE','DELETE')
|
|
42
|
+
) THEN
|
|
43
|
+
RAISE EXCEPTION 'sc_fleet_board must never hold direct DML on action_items';
|
|
44
|
+
END IF;
|
|
45
|
+
END $$;
|