@team-semicolon/semicolony-cli 4.18.104 → 4.18.106
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 +1195 -1177
- package/migrations/198_meeting_record_canonical.sql +85 -0
- package/migrations/199_meeting_record_capability.sql +164 -0
- package/migrations/200_meeting_credential_scope.sql +320 -0
- 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/204_record_meeting_overload_repair.sql +463 -0
- package/migrations/205_action_item_credential_scope.sql +318 -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$;
|