@team-semicolon/semicolony-cli 4.18.103 → 4.18.105

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,85 @@
1
+ -- 198_meeting_record_canonical.sql
2
+ --
3
+ -- Make semo_ops.meetings the canonical meeting record.
4
+ --
5
+ -- The table was built in 2026-03 for a VITO STT + Notion pipeline and was
6
+ -- abandoned with it: 7 rows, none after 2026-04. The record moved to GitHub
7
+ -- Discussions, which the headless workstation cannot write — it holds no GitHub
8
+ -- credential, so 0 discussions were published after 2026-08-06 while the skill
9
+ -- correctly reported `not-published` and Slack thread delivery masked it. When
10
+ -- the router timed out on 2026-08-12 that surface went too and the minutes
11
+ -- survived only in the worker's in-memory job result.
12
+ --
13
+ -- This restores the table rather than adding a second one: `target_domain` is
14
+ -- already an ontology FK and `attendees` is already jsonb, which is the shape
15
+ -- the action-item linkage needs.
16
+ --
17
+ -- Design: docs/superpowers/specs/2026-08-12-meeting-record-db-design.md (semo-ops)
18
+ --
19
+ -- Safety: additive except for five dead VITO/Notion columns. `audio_data` is
20
+ -- deliberately NOT dropped — see the constraint at the bottom.
21
+ -- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
22
+ -- retargets the `semicolony.` qualifier to the active schema.
23
+
24
+ ALTER TABLE semicolony.meetings
25
+ ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'restricted',
26
+ ADD COLUMN IF NOT EXISTS record_body text,
27
+ ADD COLUMN IF NOT EXISTS record_source text;
28
+
29
+ -- The default is the closed value on purpose. A meeting wrongly hidden from the
30
+ -- team is recoverable; a client negotiation wrongly exposed to it is not.
31
+ ALTER TABLE semicolony.meetings
32
+ DROP CONSTRAINT IF EXISTS meetings_visibility_check;
33
+ ALTER TABLE semicolony.meetings
34
+ ADD CONSTRAINT meetings_visibility_check
35
+ CHECK (visibility IN ('team', 'restricted'));
36
+
37
+ ALTER TABLE semicolony.meetings
38
+ DROP CONSTRAINT IF EXISTS meetings_record_source_check;
39
+ ALTER TABLE semicolony.meetings
40
+ ADD CONSTRAINT meetings_record_source_check
41
+ CHECK (record_source IS NULL
42
+ OR record_source IN ('slack-worker', 'operator', 'backfill-discussion'));
43
+
44
+ -- The linkage this change exists for. Nullable: the 1,760 existing action items
45
+ -- predate the meetings table and must not be invented a source.
46
+ ALTER TABLE semicolony.action_items
47
+ ADD COLUMN IF NOT EXISTS meeting_id uuid REFERENCES semicolony.meetings(meeting_id);
48
+
49
+ -- Dead pipeline. VITO was replaced by the hive whisper backend, and Notion sync
50
+ -- was retired with the same pipeline.
51
+ ALTER TABLE semicolony.meetings
52
+ DROP COLUMN IF EXISTS vito_transcribe_id,
53
+ DROP COLUMN IF EXISTS notion_page_id,
54
+ DROP COLUMN IF EXISTS notion_url,
55
+ DROP COLUMN IF EXISTS notion_sync_status,
56
+ DROP COLUMN IF EXISTS notion_sync_error;
57
+
58
+ -- audio_data is NOT dropped.
59
+ --
60
+ -- The design called for dropping it: recordings are restricted material and the
61
+ -- worker deletes them once the transcript exists, so a column that stores them
62
+ -- permanently contradicts the policy it serves. But the pre-flight audit found
63
+ -- 2 of the 7 rows still holding bytes — 스페이스씨엘 회의 (2026-03-19, 7.6 MB) and
64
+ -- 스타스팟 회의 (2026-03-28, 6.6 MB), 14 MB total. Neither has a Discussion, so
65
+ -- these are the only copies in existence. Dropping the column would destroy
66
+ -- restricted material that no one has decided to dispose of.
67
+ --
68
+ -- The policy contradiction is closed without destroying anything: NOT VALID
69
+ -- enforces the rule on every future insert and update while tolerating the two
70
+ -- existing rows. Disposal stays a separate, explicit decision. Validate this
71
+ -- constraint and drop the column once those two recordings are dealt with.
72
+ ALTER TABLE semicolony.meetings
73
+ DROP CONSTRAINT IF EXISTS meetings_audio_data_not_stored;
74
+ ALTER TABLE semicolony.meetings
75
+ ADD CONSTRAINT meetings_audio_data_not_stored
76
+ CHECK (audio_data IS NULL) NOT VALID;
77
+
78
+ -- Idempotency key for record_meeting. A teammate re-requesting a meeting is
79
+ -- normal behaviour — on 2026-08-12 one did, and an already-successful 190-minute
80
+ -- transcription ran a second time. It must not also create a second row.
81
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_date_title
82
+ ON semicolony.meetings (meeting_date, lower(regexp_replace(title, '\s+', ' ', 'g')));
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_action_items_meeting
85
+ ON semicolony.action_items (meeting_id) WHERE meeting_id IS NOT NULL;
@@ -0,0 +1,164 @@
1
+ -- 199_meeting_record_capability.sql
2
+ --
3
+ -- One narrowly-scoped write primitive for a meeting record, modelled on
4
+ -- `178_feedback_capture_capability.sql`. `sc_app` receives EXECUTE only: it
5
+ -- still has no general DML on meetings, action_items, or knowledge_base.
6
+ --
7
+ -- The function records the meeting, projects the body into the Knowledge Fabric,
8
+ -- and creates the meeting's action items in one transaction. Action items belong
9
+ -- here rather than behind a second endpoint because `semo action-items` needs a
10
+ -- DATABASE_URL and answers `DB 연결 실패` on the workstation — the headless path
11
+ -- has never created one, which is why 1,760 action items carry no meeting. Doing
12
+ -- both here also makes them atomic: a record cannot land with its follow-ups
13
+ -- silently missing.
14
+ --
15
+ -- Restriction reuses the existing mechanism rather than inventing one. The
16
+ -- `knowledge_base_hide_restricted` policy hides a row from sc_app when
17
+ -- `metadata -> 'classification' ->> 'sensitivity' = 'restricted'`, so a
18
+ -- restricted meeting is projected with exactly that shape.
19
+
20
+ CREATE OR REPLACE FUNCTION semicolony.record_meeting(
21
+ p_title text,
22
+ p_meeting_date date,
23
+ p_meeting_type text,
24
+ p_adhoc_subtype text,
25
+ p_visibility text,
26
+ p_target_domain text,
27
+ p_attendees jsonb,
28
+ p_record_body text,
29
+ p_transcript text,
30
+ p_action_items jsonb,
31
+ p_record_source text,
32
+ p_worker_id text,
33
+ p_device_id text
34
+ )
35
+ RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
36
+ LANGUAGE plpgsql
37
+ SECURITY DEFINER
38
+ SET search_path = pg_catalog
39
+ AS $function$
40
+ DECLARE
41
+ v_meeting_id uuid;
42
+ v_kb_id bigint;
43
+ v_existing uuid;
44
+ v_norm text;
45
+ v_sensitivity text;
46
+ v_sub_key text;
47
+ BEGIN
48
+ IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
49
+ RAISE EXCEPTION 'title must be at least 3 characters';
50
+ END IF;
51
+ IF p_meeting_date IS NULL THEN
52
+ RAISE EXCEPTION 'meeting_date is required';
53
+ END IF;
54
+ IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
55
+ RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
56
+ END IF;
57
+ IF p_record_source IS NULL
58
+ OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
59
+ RAISE EXCEPTION 'invalid record_source %', p_record_source;
60
+ END IF;
61
+ IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
62
+ RAISE EXCEPTION 'record_body is required';
63
+ END IF;
64
+
65
+ v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
66
+ v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
67
+
68
+ SELECT m.meeting_id INTO v_existing
69
+ FROM semicolony.meetings m
70
+ WHERE m.meeting_date = p_meeting_date
71
+ AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
72
+
73
+ IF v_existing IS NOT NULL THEN
74
+ UPDATE semicolony.meetings m
75
+ SET record_body = COALESCE(p_record_body, m.record_body),
76
+ mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
77
+ visibility = p_visibility,
78
+ record_source = p_record_source,
79
+ target_domain = COALESCE(p_target_domain, m.target_domain),
80
+ attendees = COALESCE(p_attendees, m.attendees),
81
+ updated_at = now()
82
+ WHERE m.meeting_id = v_existing;
83
+ v_meeting_id := v_existing;
84
+ ELSE
85
+ INSERT INTO semicolony.meetings (
86
+ meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
87
+ visibility, record_body, mapped_transcript, record_source, target_domain,
88
+ transcription_status, speaker_map, created_at, updated_at
89
+ ) VALUES (
90
+ gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
91
+ COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
92
+ p_transcript, p_record_source, p_target_domain,
93
+ 'completed', '{}'::jsonb, now(), now()
94
+ ) RETURNING meetings.meeting_id INTO v_meeting_id;
95
+ END IF;
96
+
97
+ v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
98
+
99
+ INSERT INTO semicolony.knowledge_base (
100
+ domain, key, sub_key, content, metadata, created_by, created_at, updated_at
101
+ ) VALUES (
102
+ COALESCE(p_target_domain, 'semicolon'),
103
+ 'meeting',
104
+ v_sub_key,
105
+ p_record_body,
106
+ jsonb_build_object(
107
+ 'classification', jsonb_build_object('sensitivity', v_sensitivity),
108
+ 'meeting_id', v_meeting_id,
109
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
110
+ 'record_source', p_record_source
111
+ ),
112
+ COALESCE(p_worker_id, 'meeting-worker'),
113
+ now(), now()
114
+ )
115
+ ON CONFLICT (domain, key, sub_key) DO UPDATE
116
+ SET content = EXCLUDED.content,
117
+ metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
118
+ updated_at = now()
119
+ RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
120
+
121
+ -- Re-recording the same meeting must not duplicate its follow-ups. Existing
122
+ -- machine-created items for this meeting are replaced; anything a human filed
123
+ -- separately carries no meeting_id and is untouched.
124
+ IF p_action_items IS NOT NULL AND jsonb_typeof(p_action_items) = 'array'
125
+ AND jsonb_array_length(p_action_items) > 0 THEN
126
+ DELETE FROM semicolony.action_items a WHERE a.meeting_id = v_meeting_id;
127
+
128
+ INSERT INTO semicolony.action_items (
129
+ owner_domain, target_domain, description, assignee, deadline,
130
+ priority, status, source, meeting_id, created_at, updated_at
131
+ )
132
+ SELECT
133
+ NULLIF(btrim(item->>'owner'), ''),
134
+ NULLIF(btrim(item->>'target'), ''),
135
+ btrim(item->>'description'),
136
+ NULLIF(btrim(item->>'assignee'), ''),
137
+ NULLIF(btrim(item->>'deadline'), '')::date,
138
+ COALESCE(NULLIF(btrim(item->>'priority'), ''), 'normal'),
139
+ 'open',
140
+ 'meeting',
141
+ v_meeting_id,
142
+ now(), now()
143
+ FROM jsonb_array_elements(p_action_items) AS item
144
+ WHERE COALESCE(btrim(item->>'description'), '') <> '';
145
+ END IF;
146
+
147
+ RETURN QUERY
148
+ SELECT v_meeting_id,
149
+ v_kb_id,
150
+ COALESCE(ARRAY(SELECT a.action_item_id
151
+ FROM semicolony.action_items a
152
+ WHERE a.meeting_id = v_meeting_id
153
+ ORDER BY a.created_at), '{}'::uuid[]),
154
+ (v_existing IS NOT NULL);
155
+ END;
156
+ $function$;
157
+
158
+ REVOKE ALL ON FUNCTION semicolony.record_meeting(
159
+ text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
160
+ ) FROM PUBLIC;
161
+
162
+ GRANT EXECUTE ON FUNCTION semicolony.record_meeting(
163
+ text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
164
+ ) TO sc_app;
@@ -0,0 +1,320 @@
1
+ -- 200_meeting_credential_scope.sql
2
+ --
3
+ -- Allow `meetings:write` on worker gateway credentials and grant it to the one
4
+ -- device that runs the meeting worker.
5
+ --
6
+ -- The workstation records a meeting through the gateway because it holds no
7
+ -- DATABASE_URL by policy and no GitHub credential at all. Without this scope the
8
+ -- capability added in 199 is unreachable from the only host that needs it.
9
+ --
10
+ -- Rollout follows `179_worker_feedback_credential_scope.sql`: extend the scope
11
+ -- allowlist on both governed entry points, then backfill the specific binding.
12
+ -- Unlike 179 the backfill is deliberately narrowed to a single device — only the
13
+ -- workstation runs the meeting worker, and no other worker should gain the
14
+ -- ability to write a meeting record.
15
+ --
16
+ -- The two function bodies below are the live definitions with `meetings:write`
17
+ -- added to the scope allowlist and nothing else changed. They were generated
18
+ -- from pg_get_functiondef rather than retyped so the ~10KB of unrelated
19
+ -- validation logic cannot drift. Replacing a function preserves its existing
20
+ -- privileges, so the ACLs are intentionally left untouched.
21
+ --
22
+ -- Observed while writing this, not fixed here: `workspace:assets-write` is held
23
+ -- by two live credentials but is absent from both allowlists — 192 grants it
24
+ -- through its own dedicated function instead. That makes
25
+ -- `amend_worker_gateway_credential_scopes` unusable on any credential holding it
26
+ -- without silently dropping the scope. Worth closing separately; widening
27
+ -- credential authz beyond this change's need would be the wrong place for it.
28
+ --
29
+ -- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
30
+ -- retargets the `semicolony.` qualifier to the active schema.
31
+
32
+ CREATE OR REPLACE FUNCTION semicolony.issue_worker_gateway_credential(p_team_tenant_id uuid, p_worker_id text, p_device_id text, p_token_hash text, p_token_prefix text, p_scopes text[], p_issued_by text, p_expires_days integer, p_workspace_grants jsonb)
33
+ RETURNS uuid
34
+ LANGUAGE plpgsql
35
+ SECURITY DEFINER
36
+ SET search_path TO 'pg_catalog'
37
+ AS $function$
38
+ DECLARE
39
+ v_credential_id uuid;
40
+ v_previous_credential_id uuid;
41
+ v_workspace_grants jsonb := coalesce(p_workspace_grants, '[]'::jsonb);
42
+ v_grant jsonb;
43
+ v_project_id text;
44
+ v_capability text;
45
+ v_environments text[];
46
+ v_grant_key text;
47
+ v_seen_grant_keys text[] := ARRAY[]::text[];
48
+ v_has_credential_grant boolean := false;
49
+ v_has_asset_grant boolean := false;
50
+ BEGIN
51
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
52
+ OR p_device_id IS NULL OR p_device_id !~ '^[a-z][a-z0-9-]{0,99}$' THEN
53
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
54
+ END IF;
55
+ IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$' THEN
56
+ RAISE EXCEPTION 'invalid worker credential token hash' USING ERRCODE = '22023';
57
+ END IF;
58
+ IF p_token_prefix IS NULL
59
+ OR p_token_prefix !~ ('^wck_' || p_worker_id || '_[A-Za-z0-9_-]{6}$') THEN
60
+ RAISE EXCEPTION 'invalid worker credential token prefix' USING ERRCODE = '22023';
61
+ END IF;
62
+ IF p_issued_by IS NULL OR p_issued_by <> btrim(p_issued_by)
63
+ OR btrim(p_issued_by) = '' OR char_length(p_issued_by) > 128
64
+ OR p_issued_by ~ '[[:cntrl:]]' THEN
65
+ RAISE EXCEPTION 'invalid credential issuer' USING ERRCODE = '22023';
66
+ END IF;
67
+ IF p_expires_days IS NULL OR p_expires_days < 1 OR p_expires_days > 90 THEN
68
+ RAISE EXCEPTION 'worker credential expiry must be between 1 and 90 days' USING ERRCODE = '22023';
69
+ END IF;
70
+ IF p_scopes IS NULL OR cardinality(p_scopes) = 0
71
+ OR EXISTS (
72
+ SELECT 1
73
+ FROM unnest(p_scopes) AS requested(scope)
74
+ WHERE requested.scope IS NULL
75
+ OR requested.scope <> ALL (ARRAY[
76
+ 'kb:read',
77
+ 'feedback:write',
78
+ 'meetings:write',
79
+ 'skills:read',
80
+ 'skills:write',
81
+ 'workspace:credentials-read',
82
+ 'workspace:assets-read',
83
+ 'worker:release:read',
84
+ 'worker:release:report'
85
+ ]::text[])
86
+ )
87
+ OR cardinality(p_scopes) <> (
88
+ SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
89
+ )
90
+ OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
91
+ RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
92
+ END IF;
93
+ IF jsonb_typeof(v_workspace_grants) <> 'array' THEN
94
+ RAISE EXCEPTION 'workspace grants must be a JSON array' USING ERRCODE = '22023';
95
+ END IF;
96
+
97
+ FOR v_grant IN SELECT item.value FROM jsonb_array_elements(v_workspace_grants) AS item(value) LOOP
98
+ IF jsonb_typeof(v_grant) <> 'object'
99
+ OR EXISTS (
100
+ SELECT 1
101
+ FROM jsonb_object_keys(v_grant) AS object_key(key)
102
+ WHERE object_key.key NOT IN ('projectId', 'capability', 'environments')
103
+ ) THEN
104
+ RAISE EXCEPTION 'invalid workspace grant shape' USING ERRCODE = '22023';
105
+ END IF;
106
+ v_project_id := v_grant ->> 'projectId';
107
+ v_capability := v_grant ->> 'capability';
108
+ IF v_project_id IS NULL OR v_project_id !~ '^[a-z][a-z0-9-]{0,99}$'
109
+ OR v_capability NOT IN ('credentials-read', 'assets-read')
110
+ OR jsonb_typeof(v_grant -> 'environments') <> 'array' THEN
111
+ RAISE EXCEPTION 'invalid workspace grant' USING ERRCODE = '22023';
112
+ END IF;
113
+ v_environments := ARRAY(
114
+ SELECT jsonb_array_elements_text(v_grant -> 'environments')
115
+ );
116
+ IF cardinality(v_environments) <> (
117
+ SELECT count(DISTINCT environment.value)::integer
118
+ FROM unnest(v_environments) AS environment(value)
119
+ ) OR NOT (v_environments <@ ARRAY['dev', 'e2e']::text[])
120
+ OR (v_capability = 'credentials-read' AND cardinality(v_environments) = 0)
121
+ OR (v_capability = 'assets-read' AND cardinality(v_environments) <> 0) THEN
122
+ RAISE EXCEPTION 'invalid workspace grant environments' USING ERRCODE = '22023';
123
+ END IF;
124
+ v_grant_key := v_project_id || ':' || v_capability;
125
+ IF v_grant_key = ANY(v_seen_grant_keys) THEN
126
+ RAISE EXCEPTION 'duplicate workspace grant' USING ERRCODE = '22023';
127
+ END IF;
128
+ v_seen_grant_keys := array_append(v_seen_grant_keys, v_grant_key);
129
+ v_has_credential_grant := v_has_credential_grant OR v_capability = 'credentials-read';
130
+ v_has_asset_grant := v_has_asset_grant OR v_capability = 'assets-read';
131
+ END LOOP;
132
+ IF v_has_credential_grant <> ('workspace:credentials-read' = ANY(p_scopes))
133
+ OR v_has_asset_grant <> ('workspace:assets-read' = ANY(p_scopes)) THEN
134
+ RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
135
+ END IF;
136
+
137
+ PERFORM t.id
138
+ FROM public.tenants AS t
139
+ WHERE t.id = p_team_tenant_id
140
+ AND t.slug = 'team-semicolon'
141
+ AND t.tenant_type = 'team'
142
+ AND coalesce(t.metadata ->> 'status', 'active') = 'active';
143
+ IF NOT FOUND THEN
144
+ RAISE EXCEPTION 'active team-semicolon tenant not found' USING ERRCODE = 'P0002';
145
+ END IF;
146
+
147
+ SELECT wd.gateway_credential_id
148
+ INTO v_previous_credential_id
149
+ FROM semicolony.worker_devices AS wd
150
+ WHERE wd.device_id = p_device_id
151
+ AND wd.worker_id = p_worker_id
152
+ AND wd.status = 'active'
153
+ AND wd.kb_access_mode = 'worker-gateway'
154
+ AND wd.last_doctor_status = 'pass'
155
+ AND wd.last_seen_at >= clock_timestamp() - interval '24 hours'
156
+ FOR UPDATE;
157
+ IF NOT FOUND THEN
158
+ RAISE EXCEPTION 'active worker device binding not found' USING ERRCODE = 'P0002';
159
+ END IF;
160
+
161
+ INSERT INTO semicolony.gateway_credentials (
162
+ tenant_id, tenant_slug, token_hash, token_prefix, scopes, issued_by, expires_at, metadata
163
+ ) VALUES (
164
+ p_team_tenant_id,
165
+ 'team-semicolon',
166
+ p_token_hash,
167
+ p_token_prefix,
168
+ p_scopes,
169
+ p_issued_by,
170
+ clock_timestamp() + make_interval(days => p_expires_days),
171
+ jsonb_build_object(
172
+ 'credential_kind', 'worker',
173
+ 'worker_id', p_worker_id,
174
+ 'device_id', p_device_id,
175
+ 'tenant_anchor', 'team-semicolon',
176
+ 'lineage_status', 'active'
177
+ )
178
+ )
179
+ RETURNING id INTO v_credential_id;
180
+
181
+ INSERT INTO semicolony.workspace_projection_grants (
182
+ gateway_credential_id, project_id, capability, environments
183
+ )
184
+ SELECT
185
+ v_credential_id,
186
+ item.value ->> 'projectId',
187
+ item.value ->> 'capability',
188
+ ARRAY(SELECT jsonb_array_elements_text(item.value -> 'environments'))
189
+ FROM jsonb_array_elements(v_workspace_grants) AS item(value);
190
+
191
+ IF v_previous_credential_id IS NOT NULL THEN
192
+ UPDATE semicolony.gateway_credentials
193
+ SET status = 'revoked',
194
+ revoked_at = clock_timestamp(),
195
+ revoked_reason = 'worker credential rotated'
196
+ WHERE id = v_previous_credential_id
197
+ AND status = 'active';
198
+ END IF;
199
+
200
+ UPDATE semicolony.worker_devices
201
+ SET gateway_credential_id = v_credential_id
202
+ WHERE device_id = p_device_id
203
+ AND worker_id = p_worker_id;
204
+
205
+ RETURN v_credential_id;
206
+ END;
207
+ $function$;
208
+
209
+ CREATE OR REPLACE FUNCTION semicolony.amend_worker_gateway_credential_scopes(p_credential_id uuid, p_scopes text[], p_amended_by text, p_reason text)
210
+ RETURNS uuid
211
+ LANGUAGE plpgsql
212
+ SECURITY DEFINER
213
+ SET search_path TO 'pg_catalog'
214
+ AS $function$
215
+ DECLARE
216
+ v_amendment_id uuid;
217
+ v_previous_scopes text[];
218
+ v_metadata jsonb;
219
+ v_has_credential_grant boolean;
220
+ v_has_asset_grant boolean;
221
+ BEGIN
222
+ IF p_credential_id IS NULL THEN
223
+ RAISE EXCEPTION 'credential id is required' USING ERRCODE = '22023';
224
+ END IF;
225
+ IF NULLIF(btrim(coalesce(p_amended_by, '')), '') IS NULL
226
+ OR NULLIF(btrim(coalesce(p_reason, '')), '') IS NULL THEN
227
+ RAISE EXCEPTION 'amended_by and reason are required' USING ERRCODE = '22023';
228
+ END IF;
229
+
230
+ -- Same scope contract as issuance. Kept literal rather than shared so a
231
+ -- change to one is a visible, reviewed change to the other.
232
+ IF p_scopes IS NULL OR cardinality(p_scopes) = 0
233
+ OR EXISTS (
234
+ SELECT 1
235
+ FROM unnest(p_scopes) AS requested(scope)
236
+ WHERE requested.scope IS NULL
237
+ OR requested.scope <> ALL (ARRAY[
238
+ 'kb:read',
239
+ 'feedback:write',
240
+ 'meetings:write',
241
+ 'skills:read',
242
+ 'skills:write',
243
+ 'workspace:credentials-read',
244
+ 'workspace:assets-read',
245
+ 'worker:release:read',
246
+ 'worker:release:report'
247
+ ]::text[])
248
+ )
249
+ OR cardinality(p_scopes) <> (
250
+ SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
251
+ )
252
+ OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
253
+ RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
254
+ END IF;
255
+
256
+ SELECT gc.scopes, gc.metadata
257
+ INTO v_previous_scopes, v_metadata
258
+ FROM semicolony.gateway_credentials AS gc
259
+ WHERE gc.id = p_credential_id
260
+ AND gc.status = 'active'
261
+ AND gc.revoked_at IS NULL
262
+ AND (gc.expires_at IS NULL OR gc.expires_at > clock_timestamp())
263
+ AND gc.metadata ->> 'credential_kind' = 'worker'
264
+ FOR UPDATE;
265
+ IF NOT FOUND THEN
266
+ RAISE EXCEPTION 'active worker credential not found' USING ERRCODE = 'P0002';
267
+ END IF;
268
+
269
+ -- Workspace scopes stay bound to real grants, exactly as at issuance.
270
+ SELECT
271
+ bool_or(wpg.capability = 'credentials-read'),
272
+ bool_or(wpg.capability = 'assets-read')
273
+ INTO v_has_credential_grant, v_has_asset_grant
274
+ FROM semicolony.workspace_projection_grants AS wpg
275
+ WHERE wpg.gateway_credential_id = p_credential_id;
276
+
277
+ IF coalesce(v_has_credential_grant, false) <> ('workspace:credentials-read' = ANY(p_scopes))
278
+ OR coalesce(v_has_asset_grant, false) <> ('workspace:assets-read' = ANY(p_scopes)) THEN
279
+ RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
280
+ END IF;
281
+
282
+ INSERT INTO semicolony.gateway_credential_scope_amendments (
283
+ credential_id, previous_scopes, next_scopes, reason, amended_by
284
+ ) VALUES (
285
+ p_credential_id, v_previous_scopes, p_scopes, btrim(p_reason), btrim(p_amended_by)
286
+ )
287
+ RETURNING amendment_id INTO v_amendment_id;
288
+
289
+ UPDATE semicolony.gateway_credentials
290
+ SET scopes = p_scopes
291
+ WHERE id = p_credential_id;
292
+
293
+ RETURN v_amendment_id;
294
+ END
295
+ $function$;
296
+
297
+ -- Only the workstation runs the meeting worker. Narrow on purpose: matched by
298
+ -- device id, active worker-kind credential, not already holding the scope.
299
+ UPDATE semicolony.gateway_credentials AS gc
300
+ SET scopes = array_append(gc.scopes, 'meetings:write')
301
+ WHERE gc.status = 'active'
302
+ AND gc.metadata ->> 'credential_kind' = 'worker'
303
+ AND gc.metadata ->> 'device_id' = 'semicolony-agent'
304
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
305
+ AND NOT ('meetings:write' = ANY(gc.scopes));
306
+
307
+ -- Exactly one credential may hold it. A second would mean the device filter
308
+ -- matched something unintended, and failing here is better than discovering it
309
+ -- when an unexpected host writes a meeting record.
310
+ DO $do$
311
+ DECLARE v_count int;
312
+ BEGIN
313
+ SELECT count(*) INTO v_count
314
+ FROM semicolony.gateway_credentials
315
+ WHERE status = 'active' AND 'meetings:write' = ANY(scopes);
316
+ IF v_count <> 1 THEN
317
+ RAISE EXCEPTION 'expected exactly 1 credential with meetings:write, found %', v_count;
318
+ END IF;
319
+ END
320
+ $do$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semicolony-cli",
3
- "version": "4.18.103",
3
+ "version": "4.18.105",
4
4
  "description": "SemiColony CLI - AI operations and agent orchestration installer",
5
5
  "main": "dist/bundle.js",
6
6
  "bin": {