@team-semicolon/semicolony-cli 4.18.59 → 4.18.60

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,427 @@
1
+ -- 167_worker_credential_operator_boundary.sql
2
+ --
3
+ -- Worker credential issuance is an owner-controlled operation. The CLI runs
4
+ -- through the non-owner sc_app role, so direct DML against worker_devices and
5
+ -- gateway_credentials deliberately fails. Keep the narrow operation here
6
+ -- rather than widening table grants or exposing credential hashes.
7
+ --
8
+ -- The migration runner supplies BEGIN/COMMIT. All object references are
9
+ -- schema-qualified and the SECURITY DEFINER search path is fixed.
10
+
11
+ CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(
12
+ p_team_tenant_id uuid,
13
+ p_worker_id text,
14
+ p_device_id text,
15
+ p_token_hash text,
16
+ p_token_prefix text,
17
+ p_scopes text[],
18
+ p_issued_by text,
19
+ p_expires_days integer,
20
+ p_workspace_grants jsonb
21
+ )
22
+ RETURNS uuid
23
+ LANGUAGE plpgsql
24
+ SECURITY DEFINER
25
+ SET search_path = pg_catalog
26
+ AS $function$
27
+ DECLARE
28
+ v_credential_id uuid;
29
+ v_previous_credential_id uuid;
30
+ v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
31
+ v_grant jsonb;
32
+ v_project_id text;
33
+ v_capability text;
34
+ v_environments text[];
35
+ v_grant_key text;
36
+ v_seen_grant_keys text[] := ARRAY[]::text[];
37
+ v_has_credential_grant boolean := false;
38
+ v_has_asset_grant boolean := false;
39
+ BEGIN
40
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
41
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
42
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
43
+ END IF;
44
+ IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
45
+ RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
46
+ END IF;
47
+ IF p_token_prefix IS NULL
48
+ OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
49
+ RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
50
+ END IF;
51
+ IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
52
+ OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
53
+ OR p_issued_by ~ '[[:cntrl:]]' THEN
54
+ RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
55
+ END IF;
56
+ IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
57
+ RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
58
+ END IF;
59
+ IF p_scopes IS NULL OR cardinality(p_scopes) = 0
60
+ OR EXISTS (
61
+ SELECT 1
62
+ FROM unnest(p_scopes) AS requested(scope)
63
+ WHERE requested.scope IS NULL
64
+ OR requested.scope <> ALL (ARRAY[
65
+ 'kb:read',
66
+ 'skills:read',
67
+ 'skills:write',
68
+ 'workspace:credentials-read',
69
+ 'workspace:assets-read'
70
+ ]::text[])
71
+ )
72
+ OR cardinality(p_scopes) <> (
73
+ SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
74
+ )
75
+ OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
76
+ RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
77
+ END IF;
78
+ IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
79
+ RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
80
+ END IF;
81
+
82
+ FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
83
+ IF jsonb_typeof(v_grant) <> 'object'
84
+ OR EXISTS (
85
+ SELECT 1
86
+ FROM jsonb_object_keys(v_grant) AS object_key(key)
87
+ WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
88
+ ) THEN
89
+ RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
90
+ END IF;
91
+ v_project_id := v_grant ->> 'projectId';
92
+ v_capability := v_grant ->> 'capability';
93
+ IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
94
+ OR v_capability NOT IN ('credentials-read', 'assets-read')
95
+ OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
96
+ RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
97
+ END IF;
98
+ v_environments := ARRAY(
99
+ SELECT jsonb_array_elements_text(v_grant -> 'environments')
100
+ );
101
+ IF cardinality(v_environments) <> (
102
+ SELECT count(DISTINCT environment.value)::integer
103
+ FROM unnest(v_environments) AS environment(value)
104
+ ) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
105
+ OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
106
+ OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
107
+ RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
108
+ END IF;
109
+ v_grant_key := v_project_id || ':' || v_capability;
110
+ IF v_grant_key = ANY(v_seen_grant_keys) THEN
111
+ RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
112
+ END IF;
113
+ v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
114
+ v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
115
+ v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
116
+ END LOOP;
117
+ IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
118
+ OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
119
+ RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
120
+ END IF;
121
+
122
+ PERFORM t.id
123
+ FROM public.tenants AS t
124
+ WHERE t.id = p_team_tenant_id
125
+ AND t.slug = 'team-semicolon'
126
+ AND t.tenant_type = 'team'
127
+ AND coalesce(t.metadata ->> 'status', 'active') = 'active';
128
+ IF NOT FOUND THEN
129
+ RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
130
+ END IF;
131
+
132
+ SELECT wd.gateway_credential_id
133
+ INTO v_previous_credential_id
134
+ FROM semicolony.worker_devices AS wd
135
+ WHERE wd.device_id = p_device_id
136
+ AND wd.worker_id = p_worker_id
137
+ AND wd.status = 'active'
138
+ AND wd.kb_access_mode = 'worker-gateway'
139
+ AND wd.last_doctor_status = 'pass'
140
+ AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
141
+ FOR UPDATE;
142
+ IF NOT FOUND THEN
143
+ RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
144
+ END IF;
145
+
146
+ INSERT INTO semicolony.gateway_credentials (
147
+ tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
148
+ ) VALUES (
149
+ p_team_tenant_id,
150
+ 'team-semicolon',
151
+ p_token_hash,
152
+ p_token_prefix,
153
+ p_scopes,
154
+ p_issued_by,
155
+ clock_timestamp() + make_interval(days => p_expires_days),
156
+ jsonb_build_object(
157
+ 'credential_kind', 'worker',
158
+ 'worker_id', p_worker_id,
159
+ 'device_id', p_device_id,
160
+ 'tenant_anchor', 'team-semicolon',
161
+ 'lineage_status', 'active'
162
+ )
163
+ )
164
+ RETURNING id INTO v_credential_id;
165
+
166
+ INSERT INTO semicolony.workspace_projection_grants (
167
+ gateway_credential_id, project_id, capability, environments
168
+ )
169
+ SELECT
170
+ v_credential_id,
171
+ item.value ->> 'projectId',
172
+ item.value ->> 'capability',
173
+ ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
174
+ FROM jsonb_array_elements(v_workspace_grants) AS item(value);
175
+
176
+ IF v_previous_credential_id IS NOT NULL THEN
177
+ UPDATE semicolony.gateway_credentials
178
+ SET status = 'revoked',
179
+ revoked_at = clock_timestamp(),
180
+ revoked_reason = 'worker credential rotated'
181
+ WHERE id = v_previous_credential_id
182
+ AND status = 'active';
183
+ END IF;
184
+
185
+ UPDATE semicolony.worker_devices
186
+ SET gateway_credential_id = v_credential_id
187
+ WHERE device_id = p_device_id
188
+ AND worker_id = p_worker_id;
189
+
190
+ RETURN v_credential_id;
191
+ END;
192
+ $function$;
193
+
194
+ CREATE OR REPLACE FUNCTION semicolony.list_worker_credential_metadata(p_worker_id text)
195
+ RETURNS TABLE (
196
+ id uuid,
197
+ token_prefix text,
198
+ scopes text[],
199
+ status text,
200
+ issued_at timestamptz,
201
+ expires_at timestamptz,
202
+ last_used_at timestamptz
203
+ )
204
+ LANGUAGE sql
205
+ SECURITY DEFINER
206
+ SET search_path = pg_catalog
207
+ AS $function$
208
+ SELECT gc.id,
209
+ gc.token_prefix,
210
+ gc.scopes,
211
+ gc.status,
212
+ gc.issued_at,
213
+ gc.expires_at,
214
+ gc.last_used_at
215
+ FROM semicolony.gateway_credentials AS gc
216
+ WHERE p_worker_id ~ '^[a-z][a-z0-9-]{0,63}$'
217
+ AND gc.tenant_slug = 'team-semicolon'
218
+ AND gc.metadata ->> 'credential_kind' = 'worker'
219
+ AND gc.metadata ->> 'worker_id' = p_worker_id
220
+ ORDER BY gc.issued_at DESC;
221
+ $function$;
222
+
223
+ REVOKE ALL ON FUNCTION semicolony.issue_worker_gateway_credential(
224
+ uuid, text, text, text, text, text[], text, integer, jsonb
225
+ ) FROM PUBLIC, sc_app, sc_provider;
226
+ REVOKE ALL ON FUNCTION semicolony.list_worker_credential_metadata(text)
227
+ FROM PUBLIC, sc_app, sc_provider;
228
+ GRANT EXECUTE ON FUNCTION semicolony.issue_worker_gateway_credential(
229
+ uuid, text, text, text, text, text[], text, integer, jsonb
230
+ ) TO sc_app;
231
+ GRANT EXECUTE ON FUNCTION semicolony.list_worker_credential_metadata(text) TO sc_app;
232
+
233
+ -- Leases remain immutable audit rows, but the user-facing CLI cannot be
234
+ -- expected to connect as the schema owner. These narrow routines preserve the
235
+ -- owner boundary while re-validating every authority field at execution time.
236
+ CREATE OR REPLACE FUNCTION semicolony.issue_skill_capability_lease(
237
+ p_credential_id uuid,
238
+ p_worker_id text,
239
+ p_device_id text,
240
+ p_task_id text,
241
+ p_profile text,
242
+ p_operations text[],
243
+ p_resources text[],
244
+ p_expires_minutes integer,
245
+ p_approval_reference text
246
+ )
247
+ RETURNS TABLE (
248
+ lease_id uuid,
249
+ lease_family_id uuid,
250
+ lease_version integer,
251
+ not_before timestamptz,
252
+ expires_at timestamptz
253
+ )
254
+ LANGUAGE plpgsql
255
+ SECURITY DEFINER
256
+ SET search_path = pg_catalog
257
+ AS $function$
258
+ DECLARE
259
+ v_issued_at timestamptz;
260
+ v_lease_id uuid;
261
+ v_lease_family_id uuid;
262
+ v_lease_version integer;
263
+ v_expires_at timestamptz;
264
+ BEGIN
265
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
266
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$'
267
+ OR p_task_id IS NULL OR p_task_id = '' OR p_task_id <> btrim(p_task_id)
268
+ OR char_length(p_task_id) > 160 OR p_task_id ~ '[[:cntrl:]]' THEN
269
+ RAISE EXCEPTION 'invalid skill lease identity' USING ERRCODE = '22023';
270
+ END IF;
271
+ IF p_profile IS NULL OR p_profile NOT IN ('read', 'make', 'operate')
272
+ OR p_operations IS NULL OR cardinality(p_operations) = 0 OR cardinality(p_operations) > 32
273
+ OR p_resources IS NULL OR cardinality(p_resources) = 0 OR cardinality(p_resources) > 32
274
+ OR EXISTS (
275
+ SELECT 1
276
+ FROM unnest(p_operations) AS operation(value)
277
+ WHERE operation.value IS NULL OR operation.value = '*' OR operation.value <> btrim(operation.value)
278
+ OR char_length(operation.value) > 512 OR operation.value ~ '[[:cntrl:]]'
279
+ )
280
+ OR EXISTS (
281
+ SELECT 1
282
+ FROM unnest(p_resources) AS resource(value)
283
+ WHERE resource.value IS NULL OR resource.value = '*' OR resource.value <> btrim(resource.value)
284
+ OR char_length(resource.value) > 512 OR resource.value ~ '[[:cntrl:]]'
285
+ )
286
+ OR cardinality(p_operations) <> (
287
+ SELECT count(DISTINCT operation.value)::integer FROM unnest(p_operations) AS operation(value)
288
+ )
289
+ OR cardinality(p_resources) <> (
290
+ SELECT count(DISTINCT resource.value)::integer FROM unnest(p_resources) AS resource(value)
291
+ )
292
+ OR p_expires_minutes IS NULL OR p_expires_minutes < 1 OR p_expires_minutes > 60 THEN
293
+ RAISE EXCEPTION 'invalid skill lease request' USING ERRCODE = '22023';
294
+ END IF;
295
+ IF (p_profile = 'read' AND NOT (p_operations <@ ARRAY[
296
+ 'skills.health.read', 'skills.release.read'
297
+ ]::text[]))
298
+ OR (p_profile = 'make' AND NOT (p_operations <@ ARRAY[
299
+ 'skills.health.read', 'skills.release.read', 'skills.proposal.write',
300
+ 'skills.projection.reconcile', 'skills.project.write', 'skills.release.stage'
301
+ ]::text[]))
302
+ OR (p_profile = 'operate' AND NOT (p_operations <@ ARRAY[
303
+ 'skills.health.read', 'skills.release.read', 'skills.proposal.write',
304
+ 'skills.projection.reconcile', 'skills.project.write', 'skills.release.stage',
305
+ 'skills.project.publish', 'skills.release.activate', 'skills.audience.manage',
306
+ 'skills.release.retire'
307
+ ]::text[])) THEN
308
+ RAISE EXCEPTION 'operation_not_allowed_by_profile' USING ERRCODE = '22023';
309
+ END IF;
310
+ IF (p_approval_reference IS NOT NULL
311
+ AND (p_approval_reference = ''
312
+ OR p_approval_reference <> btrim(p_approval_reference)
313
+ OR char_length(p_approval_reference) > 160
314
+ OR p_approval_reference ~ '[[:cntrl:]]'))
315
+ OR (p_profile <> 'read' AND p_approval_reference IS NULL) THEN
316
+ RAISE EXCEPTION 'mutation_approval_required' USING ERRCODE = '22023';
317
+ END IF;
318
+
319
+ SELECT clock_timestamp()
320
+ INTO v_issued_at
321
+ FROM semicolony.gateway_credentials AS credential
322
+ JOIN semicolony.worker_devices AS device
323
+ ON device.gateway_credential_id = credential.id
324
+ AND device.device_id = p_device_id
325
+ AND device.worker_id = p_worker_id
326
+ AND device.status = 'active'
327
+ AND device.kb_access_mode = 'worker-gateway'
328
+ WHERE credential.id = p_credential_id
329
+ AND credential.tenant_slug = 'team-semicolon'
330
+ AND credential.status = 'active'
331
+ AND credential.scopes @> ARRAY['skills:read']::text[]
332
+ AND (p_profile = 'read' OR credential.scopes @> ARRAY['skills:write']::text[])
333
+ AND credential.metadata ->> 'credential_kind' = 'worker'
334
+ AND credential.metadata ->> 'worker_id' = p_worker_id
335
+ AND credential.metadata ->> 'device_id' = p_device_id
336
+ AND (credential.expires_at IS NULL OR credential.expires_at > clock_timestamp())
337
+ AND device.last_doctor_status = 'pass'
338
+ AND device.last_seen_at >= clock_timestamp() - interval '24 hours'
339
+ FOR SHARE OF credential, device;
340
+ IF NOT FOUND THEN
341
+ RAISE EXCEPTION 'active_worker_device_binding_not_found' USING ERRCODE = 'P0002';
342
+ END IF;
343
+
344
+ INSERT INTO semicolony.skill_capability_leases AS lease (
345
+ lease_family_id, lease_version, credential_id, principal_id, worker_id,
346
+ device_id, task_id, profile, operations, resources, not_before,
347
+ expires_at, approval_reference, issued_by_principal
348
+ ) VALUES (
349
+ gen_random_uuid(),
350
+ 1,
351
+ p_credential_id,
352
+ 'worker:' || p_worker_id,
353
+ p_worker_id,
354
+ p_device_id,
355
+ p_task_id,
356
+ p_profile,
357
+ p_operations,
358
+ p_resources,
359
+ v_issued_at,
360
+ v_issued_at + make_interval(mins => p_expires_minutes),
361
+ NULLIF(p_approval_reference, ''),
362
+ 'db-session:' || session_user::text
363
+ )
364
+ RETURNING
365
+ lease.lease_id,
366
+ lease.lease_family_id,
367
+ lease.lease_version,
368
+ lease.not_before,
369
+ lease.expires_at
370
+ INTO v_lease_id, v_lease_family_id, v_lease_version, v_issued_at, v_expires_at;
371
+
372
+ RETURN QUERY SELECT v_lease_id, v_lease_family_id, v_lease_version, v_issued_at, v_expires_at;
373
+ END;
374
+ $function$;
375
+
376
+ CREATE OR REPLACE FUNCTION semicolony.revoke_skill_capability_lease(
377
+ p_lease_id uuid,
378
+ p_reason text
379
+ )
380
+ RETURNS TABLE (
381
+ revocation_id uuid,
382
+ lease_family_id uuid,
383
+ revoked_through_version integer,
384
+ revoked_at timestamptz
385
+ )
386
+ LANGUAGE plpgsql
387
+ SECURITY DEFINER
388
+ SET search_path = pg_catalog
389
+ AS $function$
390
+ BEGIN
391
+ IF p_reason IS NULL OR p_reason = '' OR p_reason <> btrim(p_reason)
392
+ OR char_length(p_reason) > 160 OR p_reason ~ '[[:cntrl:]]' THEN
393
+ RAISE EXCEPTION 'invalid_revocation_reason' USING ERRCODE = '22023';
394
+ END IF;
395
+
396
+ RETURN QUERY
397
+ INSERT INTO semicolony.skill_capability_lease_revocations AS revocation (
398
+ lease_family_id, revoked_through_version, reason, revoked_by_principal
399
+ )
400
+ SELECT
401
+ lease.lease_family_id,
402
+ lease.lease_version,
403
+ p_reason,
404
+ 'db-session:' || session_user::text
405
+ FROM semicolony.skill_capability_leases AS lease
406
+ WHERE lease.lease_id = p_lease_id
407
+ RETURNING
408
+ revocation.revocation_id,
409
+ revocation.lease_family_id,
410
+ revocation.revoked_through_version,
411
+ revocation.revoked_at;
412
+ END;
413
+ $function$;
414
+
415
+ REVOKE ALL ON FUNCTION semicolony.issue_skill_capability_lease(
416
+ uuid, text, text, text, text, text[], text[], integer, text
417
+ ) FROM PUBLIC, sc_app, sc_provider;
418
+ REVOKE ALL ON FUNCTION semicolony.revoke_skill_capability_lease(uuid, text)
419
+ FROM PUBLIC, sc_app, sc_provider;
420
+ GRANT EXECUTE ON FUNCTION semicolony.issue_skill_capability_lease(
421
+ uuid, text, text, text, text, text[], text[], integer, text
422
+ ) TO sc_app;
423
+ GRANT EXECUTE ON FUNCTION semicolony.revoke_skill_capability_lease(uuid, text) TO sc_app;
424
+
425
+ -- The view intentionally omits gateway_credential_id and token material. It is
426
+ -- the read-only status surface used by `semo worker status`.
427
+ GRANT SELECT ON TABLE semicolony.v_worker_environment_status TO sc_app;
@@ -0,0 +1,227 @@
1
+ -- 169_worker_profile_releases.sql
2
+ -- Immutable SEMO Ops worker profile releases and per-device rollout state.
3
+ --
4
+ -- Existing workspace_profiles.version and worker_devices.installed_profile_version
5
+ -- remain bootstrap compatibility data. They are not release-completion evidence.
6
+
7
+ CREATE TABLE IF NOT EXISTS semicolony.worker_profile_releases (
8
+ release_id UUID PRIMARY KEY,
9
+ profile_id TEXT NOT NULL REFERENCES semicolony.workspace_profiles(profile_id),
10
+ version TEXT NOT NULL,
11
+ status TEXT NOT NULL DEFAULT 'staged'
12
+ CHECK (status IN ('staged', 'active', 'superseded', 'withdrawn')),
13
+ manifest JSONB NOT NULL,
14
+ manifest_sha256 TEXT NOT NULL
15
+ CHECK (manifest_sha256 ~ '^[A-Fa-f0-9]{64}$'),
16
+ source_commit TEXT NOT NULL,
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
18
+ activated_at TIMESTAMPTZ,
19
+ activated_by TEXT,
20
+ UNIQUE (profile_id, version),
21
+ CHECK (
22
+ status <> 'active'
23
+ OR (activated_at IS NOT NULL AND NULLIF(BTRIM(activated_by), '') IS NOT NULL)
24
+ )
25
+ );
26
+
27
+ COMMENT ON TABLE semicolony.worker_profile_releases IS
28
+ 'Immutable desired-state releases for SEMO Ops workspace profiles.';
29
+ COMMENT ON COLUMN semicolony.worker_profile_releases.manifest_sha256 IS
30
+ 'SHA-256 digest of the immutable release manifest.';
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_worker_profile_releases_profile_status_created
33
+ ON semicolony.worker_profile_releases (profile_id, status, created_at DESC);
34
+
35
+ CREATE OR REPLACE FUNCTION semicolony.enforce_worker_profile_release_immutable()
36
+ RETURNS TRIGGER AS $$
37
+ BEGIN
38
+ IF TG_OP = 'DELETE' THEN
39
+ RAISE EXCEPTION 'worker profile releases are immutable and cannot be deleted';
40
+ END IF;
41
+
42
+ IF NEW.release_id IS DISTINCT FROM OLD.release_id
43
+ OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
44
+ OR NEW.version IS DISTINCT FROM OLD.version
45
+ OR NEW.manifest IS DISTINCT FROM OLD.manifest
46
+ OR NEW.manifest_sha256 IS DISTINCT FROM OLD.manifest_sha256
47
+ OR NEW.source_commit IS DISTINCT FROM OLD.source_commit
48
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
49
+ RAISE EXCEPTION 'worker profile release provenance is immutable';
50
+ END IF;
51
+
52
+ IF NEW.status IS DISTINCT FROM OLD.status THEN
53
+ IF NOT (
54
+ (OLD.status = 'staged' AND NEW.status IN ('active', 'withdrawn'))
55
+ OR (OLD.status = 'active' AND NEW.status IN ('superseded', 'withdrawn'))
56
+ ) THEN
57
+ RAISE EXCEPTION 'invalid worker profile release status transition: % to %', OLD.status, NEW.status;
58
+ END IF;
59
+
60
+ IF OLD.status = 'active'
61
+ AND NEW.status <> 'active'
62
+ AND EXISTS (
63
+ SELECT 1
64
+ FROM semicolony.workspace_profiles wp
65
+ WHERE wp.active_release_id = OLD.release_id
66
+ ) THEN
67
+ RAISE EXCEPTION 'cannot change status of a release selected by a workspace profile';
68
+ END IF;
69
+ END IF;
70
+
71
+ IF NEW.activated_at IS DISTINCT FROM OLD.activated_at
72
+ OR NEW.activated_by IS DISTINCT FROM OLD.activated_by THEN
73
+ IF NOT (OLD.status = 'staged' AND NEW.status = 'active') THEN
74
+ RAISE EXCEPTION 'worker profile release activation metadata is immutable after activation';
75
+ END IF;
76
+ END IF;
77
+
78
+ RETURN NEW;
79
+ END;
80
+ $$ LANGUAGE plpgsql;
81
+
82
+ DROP TRIGGER IF EXISTS trg_worker_profile_releases_immutable
83
+ ON semicolony.worker_profile_releases;
84
+ CREATE TRIGGER trg_worker_profile_releases_immutable
85
+ BEFORE UPDATE OR DELETE ON semicolony.worker_profile_releases
86
+ FOR EACH ROW EXECUTE FUNCTION semicolony.enforce_worker_profile_release_immutable();
87
+
88
+ ALTER TABLE semicolony.workspace_profiles
89
+ ADD COLUMN IF NOT EXISTS active_release_id UUID;
90
+
91
+ ALTER TABLE semicolony.workspace_profiles
92
+ ADD CONSTRAINT workspace_profiles_active_release_id_fkey
93
+ FOREIGN KEY (active_release_id)
94
+ REFERENCES semicolony.worker_profile_releases(release_id)
95
+ ON DELETE SET NULL;
96
+
97
+ CREATE OR REPLACE FUNCTION semicolony.assert_workspace_profile_active_release()
98
+ RETURNS TRIGGER AS $$
99
+ DECLARE
100
+ selected_profile_id TEXT;
101
+ selected_status TEXT;
102
+ BEGIN
103
+ IF NEW.active_release_id IS NULL THEN
104
+ RETURN NEW;
105
+ END IF;
106
+
107
+ SELECT r.profile_id, r.status
108
+ INTO selected_profile_id, selected_status
109
+ FROM semicolony.worker_profile_releases r
110
+ WHERE r.release_id = NEW.active_release_id;
111
+
112
+ IF NOT FOUND
113
+ OR selected_profile_id IS DISTINCT FROM NEW.profile_id
114
+ OR selected_status <> 'active' THEN
115
+ RAISE EXCEPTION 'active_release_id must reference an active release for the same workspace profile';
116
+ END IF;
117
+
118
+ RETURN NEW;
119
+ END;
120
+ $$ LANGUAGE plpgsql;
121
+
122
+ DROP TRIGGER IF EXISTS trg_workspace_profiles_active_release
123
+ ON semicolony.workspace_profiles;
124
+ CREATE TRIGGER trg_workspace_profiles_active_release
125
+ BEFORE INSERT OR UPDATE OF profile_id, active_release_id ON semicolony.workspace_profiles
126
+ FOR EACH ROW EXECUTE FUNCTION semicolony.assert_workspace_profile_active_release();
127
+
128
+ ALTER TABLE semicolony.worker_devices
129
+ ADD COLUMN IF NOT EXISTS applied_release_id UUID,
130
+ ADD COLUMN IF NOT EXISTS applied_release_version TEXT,
131
+ ADD COLUMN IF NOT EXISTS applied_at TIMESTAMPTZ,
132
+ ADD CONSTRAINT worker_devices_applied_release_state_check CHECK (
133
+ (applied_release_id IS NULL AND applied_release_version IS NULL AND applied_at IS NULL)
134
+ OR (applied_release_id IS NOT NULL AND applied_release_version IS NOT NULL AND applied_at IS NOT NULL)
135
+ );
136
+
137
+ ALTER TABLE semicolony.worker_devices
138
+ ADD CONSTRAINT worker_devices_applied_release_id_fkey
139
+ FOREIGN KEY (applied_release_id)
140
+ REFERENCES semicolony.worker_profile_releases(release_id)
141
+ ON DELETE SET NULL;
142
+
143
+ CREATE OR REPLACE FUNCTION semicolony.assert_worker_device_applied_release()
144
+ RETURNS TRIGGER AS $$
145
+ DECLARE
146
+ selected_profile_id TEXT;
147
+ selected_version TEXT;
148
+ BEGIN
149
+ IF NEW.applied_release_id IS NULL THEN
150
+ RETURN NEW;
151
+ END IF;
152
+
153
+ SELECT r.profile_id, r.version
154
+ INTO selected_profile_id, selected_version
155
+ FROM semicolony.worker_profile_releases r
156
+ WHERE r.release_id = NEW.applied_release_id;
157
+
158
+ IF NOT FOUND
159
+ OR selected_profile_id IS DISTINCT FROM NEW.installed_profile_id
160
+ OR selected_version IS DISTINCT FROM NEW.applied_release_version THEN
161
+ RAISE EXCEPTION 'applied release must match the worker device profile and release version';
162
+ END IF;
163
+
164
+ RETURN NEW;
165
+ END;
166
+ $$ LANGUAGE plpgsql;
167
+
168
+ DROP TRIGGER IF EXISTS trg_worker_devices_applied_release
169
+ ON semicolony.worker_devices;
170
+ CREATE TRIGGER trg_worker_devices_applied_release
171
+ BEFORE INSERT OR UPDATE OF installed_profile_id, applied_release_id, applied_release_version, applied_at
172
+ ON semicolony.worker_devices
173
+ FOR EACH ROW EXECUTE FUNCTION semicolony.assert_worker_device_applied_release();
174
+
175
+ CREATE OR REPLACE VIEW semicolony.v_worker_environment_status AS
176
+ SELECT
177
+ wd.device_id,
178
+ wd.worker_id,
179
+ wd.display_name,
180
+ wd.hostname,
181
+ wd.os,
182
+ wd.workspace_root,
183
+ wd.installed_profile_id,
184
+ wd.installed_profile_version,
185
+ wd.tool_adapters_enabled,
186
+ wd.kb_access_mode,
187
+ wd.last_seen_at,
188
+ wd.last_doctor_status,
189
+ wd.last_doctor_summary,
190
+ wd.setup_progress,
191
+ wd.status,
192
+ wd.updated_at,
193
+ COALESCE(
194
+ jsonb_agg(
195
+ jsonb_build_object(
196
+ 'local_hive_id', lh.local_hive_id,
197
+ 'workload_type', lh.workload_type,
198
+ 'status', lh.status,
199
+ 'migration_target', lh.migration_target,
200
+ 'expiry_at', lh.expiry_at,
201
+ 'last_seen_at', lh.last_seen_at
202
+ )
203
+ ) FILTER (WHERE lh.local_hive_id IS NOT NULL),
204
+ '[]'::jsonb
205
+ ) AS local_hives,
206
+ wp.active_release_id,
207
+ active_release.version AS active_release_version,
208
+ wd.applied_release_id,
209
+ wd.applied_release_version,
210
+ wd.applied_at
211
+ FROM semicolony.worker_devices wd
212
+ LEFT JOIN semicolony.local_hives lh
213
+ ON lh.device_id = wd.device_id
214
+ LEFT JOIN semicolony.workspace_profiles wp
215
+ ON wp.profile_id = wd.installed_profile_id
216
+ LEFT JOIN semicolony.worker_profile_releases active_release
217
+ ON active_release.release_id = wp.active_release_id
218
+ GROUP BY wd.device_id, wp.active_release_id, active_release.version;
219
+
220
+ COMMENT ON COLUMN semicolony.workspace_profiles.active_release_id IS
221
+ 'Desired immutable release. Null until an operator stages and activates the first release.';
222
+ COMMENT ON COLUMN semicolony.worker_devices.applied_release_id IS
223
+ 'Last release a device successfully applied; failed updates must not advance this value.';
224
+ COMMENT ON COLUMN semicolony.worker_devices.applied_release_version IS
225
+ 'Version recorded with the last successfully applied release.';
226
+ COMMENT ON COLUMN semicolony.worker_devices.applied_at IS
227
+ 'Time the device last successfully applied its recorded release.';